C语言最佳实践!从0到1编写自己的虚拟机

原文:https://justinmeiners.github.io/lc3-vm/
原作者:Justin Meiners and Ryan Pendleton
中英对照 机翻

Write your Own Virtual Machine

By: Justin Meiners and Ryan Pendleton

作者: Justin Meiners 和 Ryan Pendleton

View the final code and other resources in the GitHub repo.

查看 GitHub repo 中的最终代码和其他资源。

1. Contents

  1. Contents 内容
  2. Introduction 引言
  3. LC-3 Architecture LC-3体系结构
  4. Assembly Examples 汇编示例
  5. Executing Programs 执行程序
  6. Implementing Instructions 执行指示
  7. Instruction Cheat Sheet 指导小抄
  8. Trap Routines 陷阱常规
  9. Trap Routine Cheat Sheet 陷阱常规作弊表
  10. Loading Programs 加载程序
  11. Memory Mapped Registers 内存映射寄存器
  12. Platform Specifics (Unix) 平台细节(Unix)
  13. Platform Specifics (Windows) 平台细节(Windows)
  14. Running the VM 运行虚拟机
  15. Alternate C++ Technique 备用 c + + 技术
  16. Related Projects 相关项目

2. Introduction

In this tutorial, I will teach you how to write your own virtual machine (VM) that can run assembly language programs, such as my friend’s 2048 or my Roguelike. If you know how to program, but would like to gain a deeper understanding of what is going on inside a computer and better understand how programming languages work, then this project is for you. Writing your own VM may sound a little scary, but I promise that you will find it to be surprisingly simple and enlightening.

在本教程中,我将教您如何编写可以运行汇编语言程序的自己的虚拟机(VM) ,比如我朋友的2048或我的 Roguelike。如果你知道如何编程,但又想更深入地了解计算机内部的运作,更好地理解编程语言是如何工作的,那么这个项目就是为你准备的。编写自己的 VM 可能听起来有点吓人,但是我保证您会发现它非常简单,并且很有启发性。

The final code is about 250 lines of C (unix, windows). All you need to know is how to read basic C or C++ and how to do binary arithmetic.

最后的代码大约是250行 c 语言(unix,windows)。你所需要知道的就是如何阅读基本的 c 或 c + + ,以及如何做二进制算术。

Note: This VM is a literate program. This means you are reading the source code right now! Each piece of code from the project will be shown and explained thoroughly, so you can be sure nothing is left out. The final code was created by “weaving” the blocks of code together.

注意: 这个 VM 是一个文学程序。这意味着您现在正在阅读源代码!项目中的每一段代码都将得到充分的展示和解释,因此您可以确保没有遗漏任何内容。最后的代码是通过将代码块“编织”在一起创建的。

What is a virtual machine?

A VM is a program that acts like a computer. It simulates a CPU along with a few other hardware components, allowing it to perform arithmetic, read and write to memory, and interact with I/O devices, just like a physical computer. Most importantly, it can understand a machine language which you can use to program it.

VM 是一个像计算机一样运行的程序。它模拟 CPU 和其他一些硬件组件,允许它执行算术运算、读写内存以及与 i/o 设备交互,就像物理计算机一样。最重要的是,它可以理解一种机器语言,你可以用它来编程。

The amount of computer hardware the VM attempts to simulate depends on its purpose. Some VMs are designed to reproduce the behavior of some particular computer, such as video game emulators. Most people don’t have an NES lying around anymore, but we can still play NES games by simulating the NES hardware in a program. These emulators must faithfully recreate every detail and major hardware component of the original device.

VM 试图模拟的计算机硬件数量取决于它的用途。一些虚拟机被设计用来重现某些特定计算机的行为,例如视频游戏模拟器。大多数人不再有任天堂了,但我们仍然可以玩任天堂游戏通过模拟任天堂硬件在一个程序。这些模拟器必须忠实地重新创建原始设备的每个细节和主要硬件组件。

Other VMs don’t act like any real computer and are entirely made up! This is primarily done to make software development easier. Imagine you wanted to create a program that ran on multiple computer architectures. A VM could offer a standard platform which provided portability for all of them. Instead of rewriting a program in different dialects of assembly for each CPU architecture, you would only need to write the small VM program in each assembly language. Each program would then be written only once in the VM’s assembly language.

其他虚拟机不像任何真正的计算机,完全是虚构的!这主要是为了使软件开发更容易。假设您想要创建一个在多个计算机体系结构上运行的程序。虚拟机可以提供一个标准平台,为所有虚拟机提供可移植性。您只需要用每种汇编语言编写小型 VM 程序,而不需要为每个 CPU 体系结构使用不同的汇编语言重写程序。然后,每个程序将只用 VM 的汇编语言编写一次。

architecture specific implementation

vm for each architecture

Note: A compiler solves a similar problem by compiling a standard high-level language to several CPU architectures. A VM creates one standard CPU architecture which is simulated on various hardware devices. One advantage of a compiler is that it has no runtime overhead while a VM does. Even though compilers do a pretty good job, writing a new one that targets multiple platforms is very difficult, so VMs are still helpful here. In practice, VMs and compilers are mixed at various levels.

注意: 编译器通过将标准高级语言编译到多个 CPU 体系结构来解决类似的问题。VM 创建一个标准的 CPU 体系结构,该体系结构在各种硬件设备上进行模拟。编译器的一个优点是它没有运行时开销,而 VM 有。尽管编译器的工作非常出色,但是编写一个针对多个平台的新编译器是非常困难的,因此虚拟机在这里仍然很有用。实际上,虚拟机和编译器在不同级别混合。

The Java Virtual Machine (JVM) is a very successful example. The JVM itself is a moderately sized program that is small enough for one programmer to understand. This has made it possible to be written for thousands of devices including phones. Once the JVM is implemented on a new device, any Java, Kotlin, or Clojure program ever written can run on it without modification. The only cost is the overhead of the VM itself and the further abstraction from the machine. Most of the time, this is a pretty good tradeoff.

Java 虚拟机(JVM)就是一个非常成功的例子。JVM 本身是一个中等大小的程序,小到程序员可以理解。这使得包括手机在内的成千上万的设备都可以使用它。一旦 JVM 在新设备上实现,任何 Java、 Kotlin 或 Clojure 程序都可以在其上运行而不需要修改。唯一的开销是 VM 本身的开销和从机器进一步抽象的开销。大多数时候,这是一个相当好的权衡。

A VM doesn’t have to be large or pervasive to provide a similar benefit. Old video games often used small VMs to provide simple scripting systems.

VM 不一定非要非常大或者非常普遍才能提供类似的好处。老式的视频游戏通常使用小型虚拟机来提供简单的脚本系统。

VMs are also useful for executing code in a secure or isolated way. One application of this is garbage collection. There is no trivial way to implement automatic garbage collection on top of C or C++ since a program cannot see its own stack or variables. However, a VM is “outside” the program it is running and can observe all of the memory references on the stack.

Vm 对于以安全或隔离的方式执行代码也很有用。其中一个应用是垃圾收集。没有简单的方法在 c 或 c + + 之上实现自动垃圾收集,因为程序不能看到自己的堆栈或变量。但是,VM 在它正在运行的程序之外,可以观察到堆栈上的所有内存引用。

Another example of this behavior is demonstrated by Ethereum smart contracts. Smart contracts are small programs which are executed by each validating node in the blockchain network. This requires the node operators to run programs on their machines that have been written by complete strangers, without any opportunity to scrutinize them beforehand. To prevent a contract from doing malicious things, they are run inside a VM that has no access to the file system, network, disc, etc. Ethereum is also a good application of the portability features that result when using a VM. Since Ethereum nodes can be run on many kinds of computers and operating systems, the use of a VM allows smart contracts to be written without any consideration of the many platforms they run on.

这种行为的另一个例子是 Ethereum 智能合同。智能合同是由区块链网络中的每个验证节点执行的小程序。这就要求节点操作员在自己的机器上运行由完全陌生的人编写的程序,而没有任何机会事先仔细检查它们。为了防止合同做恶意的事情,它们运行在一个无法访问文件系统、网络、光盘等的虚拟机中。Ethereum 也是使用 VM 时产生的可移植性特性的一个很好的应用。由于 Ethereum 节点可以在多种计算机和操作系统上运行,因此使用 VM 可以在不考虑它们运行的多个平台的情况下编写智能合同。

3. LC-3 Architecture

Our VM will simulate a fictional computer called the LC-3. The LC-3 is popular for teaching university students how to program in assembly language. It has a simplified instruction set compared to x86, but contains all the main ideas used in modern CPUs.

我们的虚拟机将模拟一台名为 LC-3的虚拟计算机。LC-3是用汇编语言教学大学生编程的通用软件。与 x86相比,它有一个简化的指令集,但包含了现代 cpu 中使用的所有主要思想。

First, we need to simulate the essential hardware components of the machine. Try to understand what each component is, but don’t worry right now if you are unsure of how it fits into the larger picture. Start by creating a C file. Each of the code snippets in this section should be placed in the global scope of this file.

首先,我们需要模拟机器的基本硬件组件。试着理解每个组件是什么,但是如果你不确定它是否适合更大的图片,现在不要担心。首先创建一个 c 文件。本节中的每个代码段都应该放在该文件的全局作用域中。

Memory

The LC-3 has 65,536 memory locations (the maximum that is addressable by a 16-bit unsigned integer 2^16), each of which stores a 16-bit value. This means it can store a total of only 128kb, which is a lot smaller than you may be used to! In our program, this memory will be stored in a simple array:

LC-3有65,536个内存位置(最大可以通过16位无符号整数2 ^ 16寻址) ,每个位存储一个16位的值。这意味着它总共只能存储128kb,这比您可能习惯的要小得多!在我们的程序中,这个内存将被存储在一个简单的数组中:

{Memory Storage {内存存储3}

/* 65536 locations */
uint16_t memory[UINT16_MAX];

Used in section 11

在第11条中使用

Registers

A register is a slot for storing a single value on the CPU. Registers are like the “workbench” of the CPU. For the CPU to work with a piece of data, it has to be in one of the registers. However, since there are just a few registers, only a minimal amount of data can be loaded at any given time. Programs work around this by loading values from memory into registers, calculating values into other registers, and then storing the final results back in memory.

寄存器是用于在 CPU 上存储单个值的槽。寄存器就像 CPU 的“工作台”。为了让 CPU 处理一段数据,它必须在寄存器中。但是,由于只有几个寄存器,因此在任何给定时间只能加载最小数量的数据。程序通过将值从内存加载到寄存器,计算值到其他寄存器,然后将最终结果存储回内存来解决这个问题。

The LC-3 has 10 total registers, each of which is 16 bits. Most of them are general purpose, but a few have designated roles.

LC-3共有10个寄存器,每个寄存器都是16位。它们大多数是通用的,但也有少数有指定的角色。

  • 8 general purpose registers ( 8个通用登记册(R0-R7)
  • 1 program counter ( 1个程序计数器(PC) register )登记册
  • 1 condition flags ( 1个条件标志(COND) register )登记册

The general purpose registers can be used to perform any program calculations. The program counter is an unsigned integer which is the address of the next instruction in memory to execute. The condition flags tell us information about the previous calculation.

通用寄存器可用于执行任何程序计算。程序计数器是一个无符号整数,它是要在内存中执行的下一条指令的地址。条件标志告诉我们有关前面计算的信息。

{Registers {登记册3}

enum
{
    R_R0 = 0,
    R_R1,
    R_R2,
    R_R3,
    R_R4,
    R_R5,
    R_R6,
    R_R7,
    R_PC, /* program counter */
    R_COND,
    R_COUNT
};

Just like the memory, we will store the registers in an array:

就像内存一样,我们将寄存器存储在一个数组中:

{Register Storage {寄存器存储3}

uint16_t reg[R_COUNT];

Used in section 11

在第11条中使用

Instruction Set

An instruction is a command which tells the CPU to do some fundamental task, such as add two numbers. Instructions have both an opcode which indicates the kind of task to perform and a set of parameters which provide inputs to the task being performed.

指令是一个命令,它告诉 CPU 执行一些基本的任务,比如两个数相加。指令有两个操作码,一个指示要执行的任务种类,另一个参数集为正在执行的任务提供输入。

Each opcode represents one task that the CPU “knows” how to do. There are just 16 opcodes in LC-3. Everything the computer can calculate is some sequence of these simple instructions. Each instruction is 16 bits long, with the left 4 bits storing the opcode. The rest of the bits are used to store the parameters.

每个操作码代表 CPU“知道”如何执行的一个任务。LC-3只有16个操作码。计算机所能计算的一切都是这些简单指令的一些顺序。每条指令长16位,左边的4位存储操作码。其余的位用于存储参数。

We will discuss, in detail, what each instruction does later. For now, define the following opcodes. Make sure they stay in this order so that they are assigned the proper enum value:

稍后我们将详细讨论每条指令的作用。现在,定义以下操作码。确保它们保持以下顺序,以便为它们分配适当的枚举值:

{Opcodes {操作码3}

enum
{
    OP_BR = 0, /* branch */
    OP_ADD,    /* add  */
    OP_LD,     /* load */
    OP_ST,     /* store */
    OP_JSR,    /* jump register */
    OP_AND,    /* bitwise and */
    OP_LDR,    /* load register */
    OP_STR,    /* store register */
    OP_RTI,    /* unused */
    OP_NOT,    /* bitwise not */
    OP_LDI,    /* load indirect */
    OP_STI,    /* store indirect */
    OP_JMP,    /* jump */
    OP_RES,    /* reserved (unused) */
    OP_LEA,    /* load effective address */
    OP_TRAP    /* execute trap */
};

Note: The Intel x86 architecture has hundreds of instructions, while others such as ARM and LC-3 have very few. Small instruction sets are referred to as RISCs while larger ones are called CISCs. Larger instruction sets typically do not provide any fundamentally new possibilities, but they often make it more convenient to write assembly for. A single instruction in CISC might take the place of several in RISC. However, they tend to be more complex and expensive for engineers to design and manufacture. This and other tradeoffs cause the designs to come in and out of style.

注意: Intel x86架构有数百条指令,而 ARM 和 LC-3等其他架构的指令很少。小的指令集称为 rics,大的指令集称为 CISCs。较大的指令集通常不提供任何新的基本可能性,但它们通常使编写汇编更加方便。CISC 中的一条指令可能代替 RISC 中的几条指令。然而,对于工程师来说,他们的设计和制造往往更加复杂和昂贵。这个和其他的折衷会导致设计进入和退出风格。

Condition Flags

The R_COND register stores condition flags which provide information about the most recently executed calculation. This allows programs to check logical conditions such as if (x > 0) { ... }.

R_cond 寄存器存储条件标志,这些条件标志提供有关最近执行的计算的信息。这允许程序检查逻辑条件,比如 if (x > 0){ … }。

Each CPU has a variety of condition flags to signal various situations. The LC-3 uses only 3 condition flags which indicate the sign of the previous calculation.

每个 CPU 都有各种各样的条件标志来表示不同的情况。LC-3只使用3个条件标志来表示前一个计算的符号。

{Condition Flags {条件标志3}

enum
{
    FL_POS = 1 << 0, /* P */
    FL_ZRO = 1 << 1, /* Z */
    FL_NEG = 1 << 2, /* N */
};

Note: (The << symbol is called the left bitshift operator. (n << k) shifts the bits of n to the left k places. Thus 1 << 2 will equal 4. Read that link if you are not familiar. It will be important.)

注意: (< < 符号称为左位移运算符。(n < k)把 n 的位移到左边 k 的位置。因此1 < 2等于4。如果你不熟悉,请阅读这个链接。这很重要。)

We are finished setting up the hardware components of our VM! After adding standard includes (see reference), your file should look something like this:

我们已经完成了虚拟机硬件组件的设置!添加标准包含(参见引用)之后,你的文件应该是这样的:

{lc3.c 3}

{Includes, 12}

{Registers, 3}
{Opcodes, 3}
{Condition Flags, 3}

Added to in section 11

在第11条中增加

4. Assembly Examples

Now let’s look at an LC-3 assembly program to get an idea of what the VM actually runs. You don’t need to know how to program assembly or understand everything that is going on. Just try to get a general idea of what is going on. Here is a simple “Hello World”:

现在让我们看一下 LC-3汇编程序,以了解 VM 实际运行的内容。您不需要知道如何编写汇编程序,也不需要理解正在发生的一切。只要试着对正在发生的事情有个大概的了解就行了。下面是一个简单的“ Hello World”:

{Hello World Assembly {你好,世界大会4}

.ORIG x3000                        ; this is the address in memory where the program will be loaded
LEA R0, HELLO_STR                  ; load the address of the HELLO_STR string into R0
PUTs                               ; output the string pointed to by R0 to the console
HALT                               ; halt the program
HELLO_STR .STRINGZ "Hello World!"  ; store this string here in the program
.END                               ; mark the end of the file

Just like in C, the program starts at the top and executes one statement at a time. However, unlike C, there are no nested scopes {} or control structures such as if or while; just a flat list of statements. This makes it much easier to execute.

就像在 c 语言中一样,程序从顶部开始,一次执行一个语句。但是,与 c 不同的是,没有嵌套的作用域{}或控制结构,如 if 或 while; 只有一个语句的平面列表。这使得执行起来更加容易。

Notice that some of the statements have names which match the opcodes we defined earlier. Previously, we learned that every instruction was 16 bits, but each line looks like it is a different number of characters. How is this inconsistency possible?

请注意,有些语句的名称与我们前面定义的操作码相匹配。在此之前,我们了解到每条指令都是16位,但是每一行看起来像是不同数量的字符。这种不一致怎么可能呢?

This is because the code we are reading is written in assembly which is a human-readable and writable form, encoded in plain text. A tool called an assembler is used to transform each line of text into a 16-bit binary instruction the VM can understand. This binary form, which is essentially an array of 16-bit instructions, is called machine code and is what the VM actually runs.

这是因为我们正在读取的代码是用汇编编写的,汇编是以纯文本编码的人类可读和可写的形式。一个名为汇编程序的工具用来将每一行文本转换成 VM 能够理解的16位二进制指令。这种二进制形式本质上是一个由16位指令组成的数组,称为机器码,并且是虚拟机实际运行的内容。

assembler diagram

Note: Although a compiler and an assembler play a similar role in development, they are not the same. An assembler simply encodes what the programmer has written in text into binary, replacing symbols with their binary representation and packing them into instructions.

注意: 虽然编译器和汇编器在开发过程中起着类似的作用,但它们是不同的。汇编程序只是简单地将程序员用文本编写的内容编码成二进制,用它们的二进制表示形式替换符号,并将它们打包成指令。

The commands .ORIG and .STRINGZ look like instructions but they aren’t. They are assembler directives which generate a piece of code or data (like macros). For example, the .STRINGZ inserts a string of characters into the program binary at the location it is written.

命令。ORIG 和。看起来像是说明书,但实际上不是。它们是生成一段代码或数据(如宏)的汇编指令。例如:。STRINGZ 在编写程序二进制文件的位置插入一个字符串。

Loops and conditions are done with a goto-like instruction. Here is another example which counts to 10.

循环和条件是通过类似 goto 的指令完成的。

{Loop Assembly {循环程序集4}

AND R0, R0, 0                      ; clear R0
LOOP                               ; label at the top of our loop
ADD R0, R0, 1                      ; add 1 to R0 and store back in R0
ADD R1, R0, -10                    ; subtract 10 from R0 and store back in R1
BRn LOOP                           ; go back to LOOP if the result was negative
... ; R0 is now 10!

Note: Learning to write assembly is not necessary for this tutorial. However, if you are interested, you can write and assemble your own LC-3 programs using the LC-3 Tools.

注意: 本教程不需要学习编写程序集。但是,如果您感兴趣,可以使用 LC-3工具编写和组装您自己的 LC-3程序。

5. Executing Programs

Once again, the previous examples are just to give you an idea of what the VM does. To write a VM, you don’t need to be fluent in assembly. As long as you follow the proper procedure for reading and executing instructions, any LC-3 program will run correctly, no matter how complicated it is. In theory, it could even run a web browser or an operating system like Linux!

再一次,前面的例子只是为了让您了解 VM 是做什么的。要编写一个 VM,不需要熟练地使用汇编语言。只要你按照正确的程序读取和执行指令,任何 LC-3程序都会正常运行,不管它有多复杂。理论上,它甚至可以运行一个网页浏览器或者像 Linux 这样的操作系统!

If you think about this property deeply, it is a philosophically remarkable idea. The programs themselves can do all kinds of intelligent things that we never anticipated and may not be able to understand, but at the same time, everything they can do is limited to the simple code we will write! We simultaneously know everything and nothing about how each program works. Turing observed this marvelous idea:

如果你深入思考这个性质,这是一个哲学上非凡的想法。程序本身可以做各种我们从未预料到的智能事情,也可能无法理解,但与此同时,它们所能做的一切都局限于我们将要编写的简单代码!我们同时知道所有的事情,却对每个程序的工作原理一无所知。图灵观察到了这个不可思议的想法:

“The view that machines cannot give rise to surprises is due, I believe, to a fallacy to which philosophers and mathematicians are particularly subject. This is the assumption that as soon as a fact is presented to a mind all consequences of that fact spring into the mind simultaneously with it. It is a very useful assumption under many circumstances, but one too easily forgets that it is false.” — Alan M. Turing

“我认为,认为机器不能带来惊喜的观点是由于哲学家和数学家们特别关注的谬论。这是一个假设,一旦一个事实被呈现给一个头脑,那个事实的所有后果就会同时涌入头脑。在许多情况下,这是一个非常有用的假设,但人们太容易忘记它是错误的。”ー阿兰 · 图灵

Procedure

Here is the procedure we need to write:

下面是我们需要编写的程序:

  1. Load one instruction from memory at the address of the PC register.
  2. Increment the PC register.
  3. Look at the opcode to determine which type of instruction it should perform.
  4. Perform the instruction using the parameters in the instruction.
  5. Go back to step 1.

You may be wondering, “if the loop keeps incrementing the PC, and we don’t have if or while, won’t it quickly run out of instructions?” No. As we mentioned before, some goto-like instructions change the execution flow by jumping the PC around.

您可能会想,“如果循环不断增加 PC,而我们没有 if 或 while,它不会很快用完指令吗?”没有。正如我们之前提到的,一些类似 goto 的指令通过跳转 PC 来改变执行流程。

Let’s start outlining this process in the main loop:

让我们开始在主循环中概述这个过程:

{Main Loop {主回路5}

int main(int argc, const char* argv[])
{
    {Load Arguments, 5}
    {Setup, 12}

    /* set the PC to starting position */
    /* 0x3000 is the default */
    enum { PC_START = 0x3000 };
    reg[R_PC] = PC_START;

    int running = 1;
    while (running)
    {
        /* FETCH */
        uint16_t instr = mem_read(reg[R_PC]++);
        uint16_t op = instr >> 12;

        switch (op)
        {
            case OP_ADD:
                {ADD, 6}
                break;
            case OP_AND:
                {AND, 7}
                break;
            case OP_NOT:
                {NOT, 7}
                break;
            case OP_BR:
                {BR, 7}
                break;
            case OP_JMP:
                {JMP, 7}
                break;
            case OP_JSR:
                {JSR, 7}
                break;
            case OP_LD:
                {LD, 7}
                break;
            case OP_LDI:
                {LDI, 6}
                break;
            case OP_LDR:
                {LDR, 7}
                break;
            case OP_LEA:
                {LEA, 7}
                break;
            case OP_ST:
                {ST, 7}
                break;
            case OP_STI:
                {STI, 7}
                break;
            case OP_STR:
                {STR, 7}
                break;
            case OP_TRAP:
                {TRAP, 8}
                break;
            case OP_RES:
            case OP_RTI:
            default:
                {BAD OPCODE, 7}
                break;
        }
    }
    {Shutdown, 12}
}

Used in section 11

在第11条中使用

While we are at the main loop let’s handle command line input to make our program usable. We expect one or more paths to VM images and present a usage string if none are given.

当我们处于主循环时,让我们处理命令行输入,以使我们的程序可用。我们希望有一个或多个到 VM 映像的路径,如果没有给出路径,则显示使用字符串。

{Load Arguments {加载参数5}

if (argc < 2)
{
    /* show usage string */
    printf("lc3 [image-file1] ...\n");
    exit(2);
}

for (int j = 1; j < argc; ++j)
{
    if (!read_image(argv[j]))
    {
        printf("failed to load image: %s\n", argv[j]);
        exit(1);
    }
}

6. Implementing Instructions

Your task now is to fill in each opcode case with a correct implementation. This is easier than it sounds. A detailed specification for each instruction is included in the project documents. The specificiation for each translates pretty easily to several lines of codes. I will demonstrate how to implement two of them here. The code for the rest can be found in the next section.

您现在的任务是用正确的实现填充每个操作码案例。这比听起来容易。项目文档中包含了每个指令的详细说明。每一个的具体化很容易转化为几行代码。我将在这里演示如何实现其中的两个。其余部分的代码可以在下一节中找到。

ADD

The ADD instruction takes two numbers, adds them together, and stores the result in a register. Its specification is found on page 526. Each ADD instruction looks like the following:

ADD 指令接受两个数字,将它们相加,并将结果存储在寄存器中。它的规格见第526页。每条 ADD 指令如下所示:

Add Encoding

The encoding shows two rows because there are two different “modes” for this instruction. Before I explain modes, let’s try to find the similarities between them. In both rows, we can see that we start with 4 bits, 0001. This is the opcode value for OP_ADD. The next 3 bits are marked DR. This stands for destination register. The destination register is where the added sum will be stored. The next 3 bits are SR1. This is the register containing the first number to add.

编码显示两行,因为该指令有两种不同的“模式”。在我解释模式之前,让我们试着找出它们之间的相似之处。在这两行中,我们可以看到我们是从4位开始的,0001。这是 op_add 的操作码值。接下来的3位标记为 dr. This 表示目标寄存器。目标寄存器是存储加和的地方。接下来的3位是 SR1。这是包含要添加的第一个数字的寄存器。

So we know where we want to store the result and we know the first number to add. The last bit of information we need is the second number to add. At this point, the two rows start to look different. Notice that on the top row the 5th bit is a 0 and in the second row it is 1. This bit indicates whether it is immediate mode or register mode. In register mode, the second number is stored in a register just like the first. This is marked SR2 and is contained in bits 2-0. Bits 3 and 4 are unused. In assembly this would be written as:

所以我们知道我们想把结果存储在哪里,也知道第一个要加的数。我们需要的最后一点信息是要添加的第二个数字。这时,两行开始看起来不同了。注意,第一行的第5位是0,第二行是1。此位指示它是立即模式还是寄存器模式。在寄存器模式下,第二个数字像第一个数字一样存储在寄存器中。这是标记为 SR2的,包含在2-0位中。第3位和第4位没有使用。在汇编中,这样写:

{Add Register Assembly {添加寄存器程序集6}

ADD R2 R0 R1 ; add the contents of R0 to R1 and store in R2.

Immediate mode is a convenience which reduces the length of a typical program. Instead of adding two values stored in separate registers, the second value is embedded in the instruction itself, marked imm5 in the diagram. This removes the need to write instructions to load the value from memory. The tradeoff is that the instruction only has room for a small number, up to 2^5=32 (unsigned) to be exact, making immediate mode primarily useful for incrementing and decrementing. In assembly, it could be written as:

即时模式是一种方便,它减少了典型程序的长度。第二个值嵌入到指令本身中,在图表中标记为 imm5,而不是添加存储在独立寄存器中的两个值。这样就不需要写入指令来从内存中加载值。这样做的代价是,指令只能容纳少量的数字,精确地说,最多可以达到2 ^ 5 = 32(无符号) ,因此立即模式主要用于递增和递减。在汇编中,可以这样写:

{Add Immediate Assembly {添加即时程序集6}

ADD R0 R0 1 ; add 1 to R0 and store back in R0

Here is a summary from the specification:

以下是该规范的总结:

If bit [5] is 0, the second source operand is obtained from SR2. If bit [5] is 1, the second source operand is obtained by sign-extending the imm5 field to 16 bits. In both cases, the second source operand is added to the contents of SR1 and the result stored in DR. (Pg. 526)

如果位[5]为0,则从 SR2获得第二个源操作数。如果位[5]为1,则通过将 imm5字段扩展为16位来获得第二源操作数。在这两种情况下,将第二个源操作数添加到 SR1的内容中,并将结果存储在 dr (Pg 中。526)

That sounds just like the behaviour we discussed, but what is “sign-extending”? The immediate mode value has only 5 bits, but it needs to be added to a 16-bit number. To do the addition, those 5 bits need to be extended to 16 to match the other number. For positive numbers, we can simply fill in 0’s for the additional bits. For negative numbers, this causes a problem. For example, -1 in 5 bits is 1 1111. If we just extended it with 0’s, this is 0000 0000 0001 1111 which is equal to 31. Sign extension corrects this problem by filling in 0’s for positive numbers and 1’s for negative numbers, so that original values are preserved.

这听起来就像我们讨论的行为,但什么是“延伸符号”?立即模式值只有5位,但需要添加到16位数字中。要做这个加法,这5个位需要扩展到16以匹配另一个数。对于正数,我们可以简单地为额外的位填入0。对于负数,这就产生了问题。例如,-1在5位是11111。如果我们把它扩展为0,这是0000000000011111,等于31。符号扩展通过为正数填写0和为负数填写1来纠正这个问题,这样原始值就被保留了。

{Sign Extend {符号延伸6}

uint16_t sign_extend(uint16_t x, int bit_count)
{
    if ((x >> (bit_count - 1)) & 1) {
        x |= (0xFFFF << bit_count);
    }
    return x;
}

Used in section 11

在第11条中使用

Note: If you are interested in exactly how negative numbers can be represented in binary, you can read about Two’s Complement. However, this is not essential. You can just copy the code above and use it whenever the specification says to sign extend numbers.

注意: 如果你对负数如何用二进制表示感兴趣,你可以阅读二进制补码。然而,这并不是必要的。您只需复制上面的代码,并在规范要求签署扩展号码时使用它。

There is one last sentence in the specification:

说明书中还有最后一句话:

The condition codes are set, based on whether the result is negative, zero, or positive. (Pg. 526)

条件代码是根据结果是否为负、零或正来设置的

Earlier we defined a condition flags enum, and now it’s time to use them. Any time a value is written to a register, we need to update the flags to indicate its sign. We will write a function so that this can be reused:

前面我们定义了一个条件标志 enum,现在是使用它们的时候了。任何时候一个值被写入寄存器,我们需要更新标志来指示它的符号。我们将编写一个函数,以便这个函数可以重用:

{Update Flags {更新旗帜6}

void update_flags(uint16_t r)
{
    if (reg[r] == 0)
    {
        reg[R_COND] = FL_ZRO;
    }
    else if (reg[r] >> 15) /* a 1 in the left-most bit indicates negative */
    {
        reg[R_COND] = FL_NEG;
    }
    else
    {
        reg[R_COND] = FL_POS;
    }
}

Used in section 11

在第11条中使用

Now we are ready to write the code for the ADD case:

现在我们准备好为 ADD 写代码了:

{ADD { ADD6}

{
    /* destination register (DR) */
    uint16_t r0 = (instr >> 9) & 0x7;
    /* first operand (SR1) */
    uint16_t r1 = (instr >> 6) & 0x7;
    /* whether we are in immediate mode */
    uint16_t imm_flag = (instr >> 5) & 0x1;

    if (imm_flag)
    {
        uint16_t imm5 = sign_extend(instr & 0x1F, 5);
        reg[r0] = reg[r1] + imm5;
    }
    else
    {
        uint16_t r2 = instr & 0x7;
        reg[r0] = reg[r1] + reg[r2];
    }

    update_flags(r0);
}

Used in section 5

第5节使用

This section contained a lot of information, so let’s summarize.

这一节包含了很多信息,所以让我们来总结一下。

  • ADD takes two values and stores them in a register. 取两个值并将它们存储在寄存器中
  • In register mode, the second value to add is found in a register. 在寄存器模式下,在寄存器中找到要添加的第二个值
  • In immediate mode, the second value is embedded in the right-most 5 bits of the instruction. 在即时模式下,第二个值嵌入指令的最右边的5位
  • Values which are shorter than 16 bits need to be sign extended. 小于16位的值需要进行符号扩展
  • Any time an instruction modifies a register, the condition flags need to be updated. 任何时候,指令修改寄存器,条件标志需要更新

You may be feeling overwhelmed about writing 15 more instructions. However, all of what you learned here will be reused. Most of the instructions use some combination of sign extension, different modes, and updating flags.

你可能会觉得写下15条说明让你不知所措。但是,您在这里学到的所有内容都将被重用。大多数指令使用一些符号扩展、不同模式和更新标志的组合。

LDI

LDI stands for “load indirect.” This instruction is used to load a value from a location in memory into a register. The specification is found on page 532.

LDI 代表“间接加载”此指令用于将值从内存中的某个位置加载到寄存器中。说明书见第532页。

Here is what the binary layout looks like:

下面是二进制布局的样子:

LDI Encoding

In contrast to ADD, there are no modes and fewer parameters. This time, the opcode is 1010 which corresponds with the OP_LDI enum value. Just like ADD, it contains a 3-bit DR (the destination register) for storing the loaded value. The remaining bits are labeled PCoffset9. This is an immediate value embedded in the instruction (similar to imm5). Since this instruction loads from memory, we can guess that this number is some kind of address which tells us where to load from. The specification provides more detail:

与 ADD 相反,它没有模式和更少的参数。这一次,操作码是1010,它对应 op_ldi enum 值。与 ADD 一样,它也包含一个3位 DR (目标寄存器) ,用于存储加载的值。剩下的位被标记为 PCoffset9。这是嵌入在指令中的直接值(类似于 imm5)。由于这个指令是从内存加载的,我们可以猜测这个数字是某种告诉我们从哪里加载的地址。该规范提供了更多细节:

An address is computed by sign-extending bits [8:0] to 16 bits and adding this value to the incremented PC. What is stored in memory at this address is the address of the data to be loaded into DR. (Pg. 532)

通过将符号扩展位[8:0]扩展到16位并将该值添加到递增的 PC 来计算地址。存储在这个地址的内存中的是要加载到 dr (Pg)中的数据的地址。532)

Just like before, we need to sign extend this 9-bit value, but this time add it to the current PC. (If you look back at the execution loop, the PC was incremented right after this instruction was loaded.) The resulting sum is an address to a location in memory, and that address contains, yet another value which is the address of the value to load.

就像以前一样,我们需要签署扩展这个9位的价值,但这一次添加到目前的 PC。(如果你回头看一下执行循环,在这个指令被加载之后 PC 就增加了。)得到的和是指向内存中某个位置的地址,该地址包含另一个值,即要加载的值的地址。

This may seem like a roundabout way to read from memory, but it is indispensable. The LD instruction is limited to address offsets that are 9 bits, whereas the memory requires 16 bits to address. LDI is useful for loading values that are stored in locations far away from the current PC, but to use it, the address of the final location needs to be stored in a neighborhood nearby. You can think of it like having a local variable in C which is a pointer to some data:

这可能看起来像是一个迂回的方式从记忆中阅读,但它是不可或缺的。LD 指令被限制为9位的偏移量,而存储器需要16位来寻址。LDI 用于加载存储在远离当前 PC 的位置的值,但是要使用它,最终位置的地址需要存储在附近的邻居中。你可以把它想象成在 c 语言中有一个局部变量,它是一个指向某些数据的指针:

{C LDI Sample { c LDI 样本6}

// the value of far_data is an address
// of course far_data itself (the location in memory containing the address) has an address
char* far_data = "apple";

// In memory it may be layed out like this:

// Address Label      Value
// 0x123:  far_data = 0x456
// ...
// 0x456:  string   = 'a'

// if PC was at 0x100
// LDI R0 0x023
// would load 'a' into R0

Same as before, the flags need to be updated after putting the value into DR:

与前面一样,在将值输入 DR 之后需要更新标志:

The condition codes are set based on whether the value loaded is negative, zero, or positive. (Pg. 532)

条件代码是根据加载的值是否为负、零或正来设置的

Here is the code for this case: (mem_read will be discussed in a later section.)

下面是这种情况的代码: (mem _ read 将在后面的小节中讨论)

{LDI { LDI6}

{
    /* destination register (DR) */
    uint16_t r0 = (instr >> 9) & 0x7;
    /* PCoffset 9*/
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
    /* add pc_offset to the current PC, look at that memory location to get the final address */
    reg[r0] = mem_read(mem_read(reg[R_PC] + pc_offset));
    update_flags(r0);
}

Used in section 5

第5节使用

As I said, this instruction shared a lot of the code and knowledge learned from ADD. You will find this is the case with the remaining instructions.

正如我所说的,这个指令共享了很多从 ADD 中学到的代码和知识。你会发现这是与剩余的指示的情况。

You now need to go back and implement the rest of the switch cases for the instructions. Follow the specification and use the code listed here to complete the others. The code for all instructions is listed at the end of the tutorial. Two of the opcodes specified before will not be used, they are OP_RTI and OP_RES. You can ignore these cases or throw an error if they are executed. After you are done, the bulk of your VM will be completed!

现在您需要返回并实现指令的其余开关案例。遵循规范并使用这里列出的代码来完成其他的规范。本教程的末尾列出了所有指令的代码。前面指定的两个操作码将不会被使用,它们是 op_rti 和 op_res。您可以忽略这些情况,或者在执行这些情况时抛出错误。在您完成之后,您的 VM 的大部分将完成!

7. Instruction Cheat Sheet

This section contains the full implementations of the remaining instructions if you get stuck.

如果您遇到困难,本节包含其余指令的完整实现。

RTI & RES

(these are unused)

(这些是未使用的)

{BAD OPCODE { BAD OPCODE7}

abort();

Used in section 5

第5节使用

Bitwise and 按位和

{AND { AND7}

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t r1 = (instr >> 6) & 0x7;
    uint16_t imm_flag = (instr >> 5) & 0x1;

    if (imm_flag)
    {
        uint16_t imm5 = sign_extend(instr & 0x1F, 5);
        reg[r0] = reg[r1] & imm5;
    }
    else
    {
        uint16_t r2 = instr & 0x7;
        reg[r0] = reg[r1] & reg[r2];
    }
    update_flags(r0);
}

Used in section 5

第5节使用

Bitwise not 不是按位的

{NOT {不是7}

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t r1 = (instr >> 6) & 0x7;

    reg[r0] = ~reg[r1];
    update_flags(r0);
}

Used in section 5

第5节使用

Branch

{BR { BR7}

{
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
    uint16_t cond_flag = (instr >> 9) & 0x7;
    if (cond_flag & reg[R_COND])
    {
        reg[R_PC] += pc_offset;
    }
}

Used in section 5

第5节使用

Jump

RET is listed as a separate instruction in the specification, since it is a different keyword in assembly. However, it is actually a special case of JMP. RET happens whenever R1 is 7.

RET 在规范中作为单独的指令列出,因为它是汇编中的不同关键字。然而,它实际上是 JMP 的一个特例。当 R1等于7时,RET 就会发生。

{JMP { JMP7}

{
    /* Also handles RET */
    uint16_t r1 = (instr >> 6) & 0x7;
    reg[R_PC] = reg[r1];
}

Used in section 5

第5节使用

Jump Register

{JSR { JSR7}

{
    uint16_t long_flag = (instr >> 11) & 1;
    reg[R_R7] = reg[R_PC];
    if (long_flag)
    {
        uint16_t long_pc_offset = sign_extend(instr & 0x7FF, 11);
        reg[R_PC] += long_pc_offset;  /* JSR */
    }
    else
    {
        uint16_t r1 = (instr >> 6) & 0x7;
        reg[R_PC] = reg[r1]; /* JSRR */
    }
    break;
}

Used in section 5

第5节使用

Load

{LD { LD7}

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
    reg[r0] = mem_read(reg[R_PC] + pc_offset);
    update_flags(r0);
}

Used in section 5

第5节使用

Load Register

{LDR { LDR7}

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t r1 = (instr >> 6) & 0x7;
    uint16_t offset = sign_extend(instr & 0x3F, 6);
    reg[r0] = mem_read(reg[r1] + offset);
    update_flags(r0);
}

Used in section 5

第5节使用

Load Effective Address

{LEA { LEA7}

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
    reg[r0] = reg[R_PC] + pc_offset;
    update_flags(r0);
}

Used in section 5

第5节使用

Store

{ST { ST7}

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
    mem_write(reg[R_PC] + pc_offset, reg[r0]);
}

Used in section 5

第5节使用

Store Indirect

{STI { STI7}

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t pc_offset = sign_extend(instr & 0x1FF, 9);
    mem_write(mem_read(reg[R_PC] + pc_offset), reg[r0]);
}

Used in section 5

第5节使用

Store Register

{STR { STR7}

{
    uint16_t r0 = (instr >> 9) & 0x7;
    uint16_t r1 = (instr >> 6) & 0x7;
    uint16_t offset = sign_extend(instr & 0x3F, 6);
    mem_write(reg[r1] + offset, reg[r0]);
}

Used in section 5

第5节使用

8. Trap Routines

The LC-3 provides a few predefined routines for performing common tasks and interacting with I/O devices. For example, there are routines for getting input from the keyboard and for displaying strings to the console. These are called trap routines which you can think of as the operating system or API for the LC-3. Each trap routine is assigned a trap code which identifies it (similar to an opcode). To execute one, the TRAP instruction is called with the trap code of the desired routine.

LC-3提供了一些预定义的例程,用于执行常见任务和与 i/o 设备交互。例如,有一些例程用于从键盘获取输入,并将字符串显示到控制台。这些例程称为 trap 例程,可以将其看作是 LC-3的操作系统或 API。每个 trap 例程都被分配一个 trap 代码来标识它(类似于操作码)。为了执行一个,使用所需例程的陷阱代码调用 TRAP 指令。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P5lX8rhI-1624889713397)(https://justinmeiners.github.io/lc3-vm/img/trap_layout.gif)]

Define an enum for each trap code:

为每个陷阱代码定义一个枚举:

{TRAP Codes {陷阱代码8}

enum
{
    TRAP_GETC = 0x20,  /* get character from keyboard, not echoed onto the terminal */
    TRAP_OUT = 0x21,   /* output a character */
    TRAP_PUTS = 0x22,  /* output a word string */
    TRAP_IN = 0x23,    /* get character from keyboard, echoed onto the terminal */
    TRAP_PUTSP = 0x24, /* output a byte string */
    TRAP_HALT = 0x25   /* halt the program */
};

Used in section 11

在第11条中使用

You may be wondering why the trap codes are not included in the instructions. This is because they do not actually introduce any new functionality to the LC-3, they just provide a convenient way to perform a task (similar to OS system calls). In the official LC-3 simulator, trap routines are written in assembly. When a trap code is called, the PC is moved to that code’s address. The CPU executes the procedure’s instructions, and when it is complete, the PC is reset to the location following the initial call.

您可能想知道为什么陷阱代码没有包括在说明书中。这是因为它们实际上并没有向 LC-3引入任何新的功能,它们只是提供了一种执行任务的方便方式(类似于 OS 系统调用)。在官方的 LC-3模拟器中,陷阱例程是用汇编语言编写的。当一个陷阱代码被调用时,PC 被移动到该代码的地址。CPU 执行过程的指令,当过程完成时,PC 被重置到初始调用之后的位置。

Note: This is why programs start at address 0x3000 instead of 0x0. The lower addresses are left empty to leave space for the trap routine code.

注意: 这就是为什么程序开始于地址0x3000而不是0x0。较低的地址留空,以便为陷阱例程代码留出空间。

There is no specification for how trap routines must be implemented, only what they are supposed to do. In our VM, we are going to do things slightly differently by writing them in C. When a trap code is invoked, a C function will be called. When it is completed, execution will return to the instructions. (If you are curious about trap codes in assembly, see Ryan’s implementation.)

没有关于如何实现 trap 例程的规范,只有它们应该做什么。在我们的 VM 中,我们将用 c 语言编写稍微不同的代码。当一个 trap 代码被调用时,一个 c 函数将被调用。当它完成时,执行将返回到指令。(如果您对程序集中的陷阱代码感到好奇,请参阅 Ryan 的实现。)

Even though the trap routines can be written in assembly and this is what a physical LC-3 computer would do, it isn’t the best fit for a VM. Instead of writing our own primitive I/O routines, we can take advantage of the ones available on our OS. This will make the VM run better on our computers, simplify the code, and provide a higher level of abstraction for portability.

尽管陷阱例程可以用汇编语言编写,而这正是物理的 LC-3计算机所能做的,但它并不是最适合 VM 的。与编写自己的原语 i/o 例程不同,我们可以利用操作系统上的可用例程。这将使 VM 在我们的计算机上运行得更好,简化代码,并为可移植性提供更高级别的抽象。

Note: Getting input from the keyboard is one specific example of this. The assembly version uses a loop to continuously check the keyboard for input. This consumes a lot of CPU time for nothing! Using a proper OS input function allows the program to sleep until input is received.

注意: 从键盘获取输入是这方面的一个具体例子。程序集版本使用循环不断检查键盘的输入。这毫无意义地消耗了大量的 CPU 时间!使用适当的操作系统输入函数允许程序休眠,直到接收到输入。

In the switch case form the TRAP opcode, add another switch:

在 TRAP 操作码的开关框中,添加另一个开关:

{TRAP { TRAP8}

switch (instr & 0xFF)
{
    case TRAP_GETC:
        {TRAP GETC, 9}
        break;
    case TRAP_OUT:
        {TRAP OUT, 9}
        break;
    case TRAP_PUTS:
        {TRAP PUTS, 8}
        break;
    case TRAP_IN:
        {TRAP IN, 9}
        break;
    case TRAP_PUTSP:
        {TRAP PUTSP, 9}
        break;
    case TRAP_HALT:
        {TRAP HALT, 9}
        break;
}

Used in sections 5 and 15

第5及15节所用

As with instructions, I will show you how to implement a single trap routine and leave the rest to you.

按照说明,我将向您展示如何实现一个单陷阱例程,剩下的就交给您了。

PUTS

The PUTS trap code is used to output a null-terminated string (similar to printf in C). The specification is found on page 543.

PUTS 陷阱代码用于输出一个空终止字符串/值(类似于 c 中的 printf)。说明书见第543页。

To display a string, we must give the trap routine a string to display. This is done by storing the address of the first character in R0 before beginning the trap.

要显示字符串,我们必须给 trap 例程一个要显示的字符串。这是通过在开始陷阱之前在 R0中存储第一个字符的地址来实现的。

The specification says:

说明书上说:

Write a string of ASCII characters to the console display. The characters are contained in consecutive memory locations, one character per memory location, starting with the address specified in R0. Writing terminates with the occurrence of x0000 in a memory location. (Pg. 543)

向控制台显示器写入一个 ASCII 字符串。这些字符包含在连续的内存位置中,从 R0中指定的地址开始,每个内存位置包含一个字符。写操作在内存位置出现 x0000时结束。(Pg.543)

Notice that unlike C strings, characters are not stored in a single byte, but in a single memory location. Memory locations in LC-3 are 16 bits, so each character in the string is 16 bits wide. To display this with a C function, we will need to convert each value to a char and output them individually.

注意,与 c 字符串不同,字符不存储在单个字节中,而是存储在单个内存位置中。LC-3中的内存位置为16位,因此字符串中的每个字符宽度为16位。为了使用 c 函数显示这个值,我们需要将每个值转换为一个 char 并单独输出它们。

{TRAP PUTS {陷阱放置8}

{
    /* one char per word */
    uint16_t* c = memory + reg[R_R0];
    while (*c)
    {
        putc((char)*c, stdout);
        ++c;
    }
    fflush(stdout);
}

That’s all for this routine. The trap routines are pretty straightforward if you are familiar with C. Go back to the specification and implement the others now. As with the instructions, the full code can be found at the end of the tutorial.

这就是我们的例行公事。如果您熟悉 c,那么陷阱例程是非常简单的。现在回到规范并实现其他的。与说明一样,完整的代码可以在教程的最后找到。

9. Trap Routine Cheat Sheet

This section contains the full implementations of the remaining trap routines.

本节包含剩余的 trap 例程的完整实现。

Input Character

输入字符

{TRAP GETC { TRAP GETC9}

/* read a single ASCII char */
reg[R_R0] = (uint16_t)getchar();

Used in section 8

在第8条中使用

Output Character

输出字符

{TRAP OUT { TRAP OUT9}

putc((char)reg[R_R0], stdout);
fflush(stdout);

Used in section 8

在第8条中使用

Prompt for Input Character

输入字符提示符

{TRAP IN {陷阱9}

{
    printf("Enter a character: ");
    char c = getchar();
    putc(c, stdout);
    reg[R_R0] = (uint16_t)c;
}

Used in section 8

在第8条中使用

Output String

输出字符串

{TRAP PUTSP { TRAP PUTSP9}

{
    /* one char per byte (two bytes per word)
       here we need to swap back to
       big endian format */
    uint16_t* c = memory + reg[R_R0];
    while (*c)
    {
        char char1 = (*c) & 0xFF;
        putc(char1, stdout);
        char char2 = (*c) >> 8;
        if (char2) putc(char2, stdout);
        ++c;
    }
    fflush(stdout);
}

Used in section 8

在第8条中使用

Halt Program

暂停程序

{TRAP HALT { TRAP HALT9}

puts("HALT");
fflush(stdout);
running = 0;

Used in section 8

在第8条中使用

10. Loading Programs

We have mentioned a lot about loading and executing instructions from memory, but how do instructions get into memory in the first place? When an assembly program is converted to machine code, the result is a file containing an array of instructions and data. This can be loaded by just copying the contents right into an address in memory.

我们已经提到了很多关于从内存中加载和执行指令的内容,但是指令是如何进入内存的呢?当程序集程序转换为机器代码时,结果是一个包含指令和数据数组的文件。这可以通过将内容复制到内存中的地址来加载。

The first 16 bits of the program file specify the address in memory where the program should start. This address is called the origin. It must be read first, after which the rest of the data can be read from the file into memory starting at the origin address.

程序文件的前16位指定程序开始的内存地址。这个地址称为原点。它必须首先被读取,然后剩余的数据可以从文件中读取到从原始地址开始的内存中。

Here is the code for reading an LC-3 program into memory:

下面是将 LC-3程序读入内存的代码:

{Read Image File {阅读图像文件10}

void read_image_file(FILE* file)
{
    /* the origin tells us where in memory to place the image */
    uint16_t origin;
    fread(&origin, sizeof(origin), 1, file);
    origin = swap16(origin);

    /* we know the maximum file size so we only need one fread */
    uint16_t max_read = UINT16_MAX - origin;
    uint16_t* p = memory + origin;
    size_t read = fread(p, sizeof(uint16_t), max_read, file);

    /* swap to little endian */
    while (read-- > 0)
    {
        *p = swap16(*p);
        ++p;
    }
}

Used in section 11

在第11条中使用

Notice that swap16 is called on each loaded value. LC-3 programs are big-endian, but most of the modern computers we use are little endian. As a result, we need to swap each uint16 that is loaded. (If you happen to be using a strange computer, like a PPC then you should not swap.)

注意,对每个加载的值调用 swap16。LC-3程序是 big-endian,但我们使用的大多数现代计算机是 little endian。因此,我们需要交换加载的每个 uint16。(如果你碰巧使用一台陌生的电脑,比如 PPC,那么你就不应该交换。)

{Swap {掉期10}

uint16_t swap16(uint16_t x)
{
    return (x << 8) | (x >> 8);
}

Used in section 11

在第11条中使用

Note: Endianness refers to how bytes of an integer are interpreted. In little-endian, the first byte is the least significant digit, and in big-endian, it is reversed. As far as I know, the decision is mostly arbitrary. Different companies made different decisions, so now we are left with varying implementations. You do not need to know anything else about endianness for this project.

注意: Endianness 指的是整数字节的解释方式。在 little-endian 中,第一个字节是最低有效位,而在 big-endian 中,第一个字节是反向的。据我所知,这个决定基本上是武断的。不同的公司做出了不同的决定,所以现在我们只剩下不同的实现方式。对于这个项目,您不需要知道任何关于 endianness 的其他信息。

Let’s also add a convenience function for read_image_file which takes a path a string;

让我们也为 read _ image _ file 添加一个方便的函数,它采用一个字符串路径;

{Read Image {阅读图片10}

int read_image(const char* image_path)
{
    FILE* file = fopen(image_path, "rb");
    if (!file) { return 0; };
    read_image_file(file);
    fclose(file);
    return 1;
}

Used in section 11

在第11条中使用

11. Memory Mapped Registers

Some special registers are not accessible from the normal register table. Instead, a special address is reserved for them in memory. To read and write to these registers, you just read and write to their memory location. These are called memory mapped registers. They are commonly used to interact with special hardware devices.

有些特殊寄存器无法从正常寄存器表中访问。相反,在内存中为它们保留了一个特殊的地址。要读写这些寄存器,你只需要读写它们的内存位置。这些被称为内存映射寄存器。它们通常用于与特殊的硬件设备进行交互。

The LC-3 has two memory mapped registers that need to be implemented. They are the keyboard status register (KBSR) and keyboard data register (KBDR). The KBSR indicates whether a key has been pressed, and the KBDR identifies which key was pressed.

LC-3有两个需要实现的内存映射寄存器。它们是键盘状态寄存器(KBSR)和键盘数据寄存器(KBDR)。KBSR 指示是否按下了某个键,KBDR 指示按下了哪个键。

Although you can request keyboard input using GETC, this blocks execution until input is received. KBSR and KBDR allows you to poll the state of the device and continue execution, so the program can stay responsive while waiting for input.

虽然可以使用 GETC 请求键盘输入,但是这会阻止执行,直到接收到输入。KBSR 和 KBDR 允许您轮询设备的状态并继续执行,因此程序可以在等待输入时保持响应。

{Memory Mapped Registers {内存映射寄存器11}

enum
{
    MR_KBSR = 0xFE00, /* keyboard status */
    MR_KBDR = 0xFE02  /* keyboard data */
};

Memory mapped registers make memory access a bit more complicated. We can’t read and write to the memory array directly, but must instead call setter and getter functions. When memory is read from KBSR, the getter will check the keyboard and update both memory locations.

内存映射寄存器使内存访问更加复杂。我们不能直接读写内存数组,而必须调用 setter 和 getter 函数。当从 KBSR 读取内存时,getter 将检查键盘并更新两个内存位置。

{Memory Access {内存访问11}

void mem_write(uint16_t address, uint16_t val)
{
    memory[address] = val;
}

uint16_t mem_read(uint16_t address)
{
    if (address == MR_KBSR)
    {
        if (check_key())
        {
            memory[MR_KBSR] = (1 << 15);
            memory[MR_KBDR] = getchar();
        }
        else
        {
            memory[MR_KBSR] = 0;
        }
    }
    return memory[address];
}

That completes the last component of the VM! Provided that you implemented the rest of the trap routines and instructions, you are almost ready to try it out!

这就完成了 VM 的最后一个组件!如果您实现了剩下的陷阱例程和指令,那么您几乎已经准备好尝试它了!

All that we have written should have been added to the C file in the following order:

我们编写的所有内容都应该按以下顺序添加到 c 文件中:

{lc3.c 3} +=

{Memory Mapped Registers, 11}
{TRAP Codes, 8}

{Memory Storage, 3}
{Register Storage, 3}

{Sign Extend, 6}
{Swap, 10}
{Update Flags, 6}
{Read Image File, 10}
{Read Image, 10}
{Check Key, 12}
{Memory Access, 11}
{Input Buffering, 12}
{Handle Interrupt, 12}

{Main Loop, 5}

12. Platform Specifics (Unix)

This section contains some tedious details that are needed to access the keyboard and behave nicely. These are not insightful or relevant to learning about VMs. Feel free to copy paste!

这一节包含了一些访问键盘和表现良好所需的繁琐细节。这些对学习虚拟机没有深刻的见解或相关性。随意复制粘贴!

NOTE: Skip to the next section for the Windows versions of these functions.

注意: 跳到下一节,了解这些函数的 Windows 版本。

{Check Key {检查键12}

uint16_t check_key()
{
    fd_set readfds;
    FD_ZERO(&readfds);
    FD_SET(STDIN_FILENO, &readfds);

    struct timeval timeout;
    timeout.tv_sec = 0;
    timeout.tv_usec = 0;
    return select(1, &readfds, NULL, NULL, &timeout) != 0;
}

Used in section 11

在第11条中使用

This is Unix specific code for setting up terminal input.

这是用于设置终端输入的 Unix 特定代码。

{Input Buffering {输入缓冲12}

struct termios original_tio;

void disable_input_buffering()
{
    tcgetattr(STDIN_FILENO, &original_tio);
    struct termios new_tio = original_tio;
    new_tio.c_lflag &= ~ICANON & ~ECHO;
    tcsetattr(STDIN_FILENO, TCSANOW, &new_tio);
}

void restore_input_buffering()
{
    tcsetattr(STDIN_FILENO, TCSANOW, &original_tio);
}

Used in section 11

在第11条中使用

{Handle Interrupt {处理中断12}

void handle_interrupt(int signal)
{
    restore_input_buffering();
    printf("\n");
    exit(-2);
}

Used in section 11

在第11条中使用

{Setup {安装程序12}

signal(SIGINT, handle_interrupt);
disable_input_buffering();

Used in section 5

第5节使用

When the program is interrupted, we want to restore the terminal settings back to normal.

当程序被中断时,我们希望将终端设置恢复到正常状态。

{Shutdown {关闭12}

restore_input_buffering();

Used in section 5

第5节使用

{Includes {包括12}

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <signal.h>
/* unix */
#include <unistd.h>
#include <fcntl.h>

#include <sys/time.h>
#include <sys/types.h>
#include <sys/termios.h>
#include <sys/mman.h>

Used in section 3

在第3节中使用

13. Platform Specifics (Windows)

This section contains some tedious details that are needed to access the keyboard and behave nicely. These are not insightful or relevant to learning about VMs. Feel free to copy paste!

这一节包含了一些访问键盘和表现良好所需的繁琐细节。这些对学习虚拟机没有深刻的见解或相关性。随意复制粘贴!

NOTE: If you already included the Unix versions don’t add these!

注意: 如果你已经包含了 Unix 版本,不要添加这些!

{Check Key Windows {检查键窗口13}

uint16_t check_key()
{
    return WaitForSingleObject(hStdin, 1000) == WAIT_OBJECT_0 && _kbhit();
}

{Input Buffering Windows {输入缓冲窗口13}

DWORD fdwMode, fdwOldMode;

void disable_input_buffering()
{
    hStdin = GetStdHandle(STD_INPUT_HANDLE);
    GetConsoleMode(hStdin, &fdwOldMode); /* save old mode */
    fdwMode = fdwOldMode
            ^ ENABLE_ECHO_INPUT  /* no input echo */
            ^ ENABLE_LINE_INPUT; /* return when one or
                                    more characters are available */
    SetConsoleMode(hStdin, fdwMode); /* set new mode */
    FlushConsoleInputBuffer(hStdin); /* clear buffer */
}

void restore_input_buffering()
{
    SetConsoleMode(hStdin, fdwOldMode);
}

{Windows Setup { Windows 安装程序13}

signal(SIGINT, handle_interrupt);
disable_input_buffering();

When the program is interrupted, we want to restore the terminal settings back to normal.

当程序被中断时,我们希望将终端设置恢复到正常状态。

{Windows Shutdown {窗口关闭13}

restore_input_buffering();

{Windows Includes { Windows 包括13}

#include <stdint.h> // uint16_t
#include <stdio.h>  // FILE
#include <signal.h> // SIGINT
/* windows only */
#include <Windows.h>
#include <conio.h>  // _kbhit

HANDLE hStdin = INVALID_HANDLE_VALUE;

14. Running the VM

You can now build and run the LC-3 VM!

您现在可以构建并运行 LC-3 VM 了!

  1. Compile your program] with your favorite C compiler. (Final source for unix, windows).

    用你最喜欢的 c 编译器编译你的程序]。

    For gcc: $ gcc lc3.c -o lc3

    用于 gcc: $gcc lc3.c-o lc3

  2. Download the assembled version of 2048 or Rogue.

    下载2048或 Rogue 的汇编版本。

  3. Run the program with the obj file as an argument:

    用 obj 文件作为参数运行程序:

    $ lc3-vm path/to/2048.obj

    $lc3-vm path/to/2048. obj

  4. Play 2048!

    玩2048!

{2048 Example {2048例子14}

Control the game using WASD keys.
Are you on an ANSI terminal (y/n)? y
+--------------------------+
|                          |
|                          |
|                          |
|                     2    |
|                          |
|   2                      |
|                          |
|                          |
|                          |
+--------------------------+

Debugging

If the program doesn’t work correctly, it is likely because you programmed an instruction incorrectly. This can be tricky to debug. I recommend reading through the assembly source code of an LC-3 program while simultaneously using a debugger to step through the VM instructions one at a time. As you read the assembly, make sure the VM goes to the instruction that you expect it to. If a discrepancy occurs, you will then know which instruction caused the issue. Reread its specification and double check your code.

如果程序不能正常工作,很可能是因为你编写的指令不正确。这可能是棘手的调试。我建议阅读一下 LC-3程序的汇编源代码,同时使用调试器逐步执行 VM 指令。在阅读程序集时,确保 VM 按照您期望的指令运行。如果出现差异,你就会知道是哪条指令导致了问题。重新阅读它的规范并仔细检查你的代码。

15. Alternate C++ Technique

This section shows an advanced way of executing instructions that makes the code a whole lot smaller. This section is entirely optional.

本节展示了一种高级的执行指令的方法,这种方法可以使代码变得更小。这一部分完全是可选的。

Since C++ has powerful compile-time generics, we can use the compiler to generate parts of the instructions for us. This method reduces code duplication and is actually closer to how the computer would be wired in hardware.

由于 c + + 具有强大的编译时泛型,我们可以使用编译器为我们生成部分指令。这种方法减少了代码重复,实际上更接近于计算机在硬件中的连接方式。

The idea here is to reuse the steps common to each instruction. For example, several of the instructions use indirect addressing or sign extend a value and add it to the current PC value. Wouldn’t it be nice if we could write this code once for all instructions?

这里的想法是重复使用每条指令的公共步骤。例如,一些指令使用间接寻址或签名扩展值并将其添加到当前 PC 值。如果我们可以为所有的指令一次性编写这段代码,不是很好吗?

By thinking of the instruction as a pipeline of steps we can see that each instruction is just a permutation of several of the smaller steps. We will use bitwise flags to mark which steps to perform for each instruction. A 1 in the bit corresponding to the instruction number indicates the compiler should include this section of code for that instruction.

通过将指令想象成一条步骤管道,我们可以看到每条指令只是几个较小步骤的排列。我们将使用位标志来标记每条指令要执行的步骤。与指令号相对应的位中的1表示编译器应该包含该指令的这部分代码。

{Instruction C++ {指令 c + +15}

template <unsigned op>
void ins(uint16_t instr)
{
    uint16_t r0, r1, r2, imm5, imm_flag;
    uint16_t pc_plus_off, base_plus_off;

    constexpr uint16_t opbit = (1 << op);
    if (0x4EEE & opbit) { r0 = (instr >> 9) & 0x7; }
    if (0x12F3 & opbit) { r1 = (instr >> 6) & 0x7; }
    if (0x0022 & opbit)
    {
        imm_flag = (instr >> 5) & 0x1;

        if (imm_flag)
        {
            imm5 = sign_extend(instr & 0x1F, 5);
        }
        else
        {
            r2 = instr & 0x7;
        }
    }
    if (0x00C0 & opbit)
    {   // Base + offset
        base_plus_off = reg[r1] + sign_extend(instr & 0x3F, 6);
    }
    if (0x4C0D & opbit)
    {
        // Indirect address
        pc_plus_off = reg[R_PC] + sign_extend(instr & 0x1FF, 9);
    }
    if (0x0001 & opbit)
    {
        // BR
        uint16_t cond = (instr >> 9) & 0x7;
        if (cond & reg[R_COND]) { reg[R_PC] = pc_plus_off; }
    }
    if (0x0002 & opbit)  // ADD
    {
        if (imm_flag)
        {
            reg[r0] = reg[r1] + imm5;
        }
        else
        {
            reg[r0] = reg[r1] + reg[r2];
        }
    }
    if (0x0020 & opbit)  // AND
    {
        if (imm_flag)
        {
            reg[r0] = reg[r1] & imm5;
        }
        else
        {
            reg[r0] = reg[r1] & reg[r2];
        }
    }
    if (0x0200 & opbit) { reg[r0] = ~reg[r1]; } // NOT
    if (0x1000 & opbit) { reg[R_PC] = reg[r1]; } // JMP
    if (0x0010 & opbit)  // JSR
    {
        uint16_t long_flag = (instr >> 11) & 1;
        reg[R_R7] = reg[R_PC];
        if (long_flag)
        {
            pc_plus_off = reg[R_PC] + sign_extend(instr & 0x7FF, 11);
            reg[R_PC] = pc_plus_off;
        }
        else
        {
            reg[R_PC] = reg[r1];
        }
    }

    if (0x0004 & opbit) { reg[r0] = mem_read(pc_plus_off); } // LD
    if (0x0400 & opbit) { reg[r0] = mem_read(mem_read(pc_plus_off)); } // LDI
    if (0x0040 & opbit) { reg[r0] = mem_read(base_plus_off); }  // LDR
    if (0x4000 & opbit) { reg[r0] = pc_plus_off; } // LEA
    if (0x0008 & opbit) { mem_write(pc_plus_off, reg[r0]); } // ST
    if (0x0800 & opbit) { mem_write(mem_read(pc_plus_off), reg[r0]); } // STI
    if (0x0080 & opbit) { mem_write(base_plus_off, reg[r0]); } // STR
    if (0x8000 & opbit)  // TRAP
    {
         {TRAP, 8}
    }
    //if (0x0100 & opbit) { } // RTI
    if (0x4666 & opbit) { update_flags(r0); }
}

{Op Table { Op Table15}

static void (*op_table[16])(uint16_t) = {
    ins<0>, ins<1>, ins<2>, ins<3>,
    ins<4>, ins<5>, ins<6>, ins<7>,
    NULL, ins<9>, ins<10>, ins<11>,
    ins<12>, NULL, ins<14>, ins<15>
};

Note: I learned about this technique from Bisqwit’s NES emulator. If you are interested in emulation, or the NES, I highly recommend his videos.

注意: 我是从 Bisqwit 的 NES 模拟器上学到这个技术的。如果你对仿真或者 NES 感兴趣,我强烈推荐他的视频。

The rest of the C++ version uses the code we already wrote! The full source is here: unix, windows.

其余的 c + + 版本使用了我们已经编写的代码。

16. Contributions

atul-g has contributed a handy reference card that summarizes how the whole system works.

Atul-g 提供了一张便利的参考卡片,总结了整个系统的工作原理。

Many programmers have completed this tutorial and shared their implementations in various languages. A selection of these used to be listed, but since there were so many, we decided to utilize GitHub tags to organize them.

许多程序员已经完成了这个教程,并且用不同的语言分享了他们的实现。以前会列出一些这样的标签,但是因为这样的标签太多了,我们决定利用 GitHub 标签来组织它们。

To list your own project, just make sure it is tagged with the GitHub topic lc3. If your language is missing, feel free to submit a pull request.

要列出你自己的项目,只需要确保它标记为 GitHub topic lc3。如果你的语言缺失,请随时提交一个拉请求。

Special thanks to inkydragon for contributing Windows platform support.

特别感谢 inkydragon 为 Windows 平台提供支持。

Want to contribute? We need help with an integration test. This is a good first issue to learn from.

想要贡献吗? 我们需要一个集成测试的帮助。这是一个很好的第一个可以学习的问题。

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
智能合约虚拟机赋予了区块链运行去中心化应用(Dapp)的能力。它让区块链演化为“操作系统”,孕育出繁荣的Dapp生态。一款优秀的VM不仅仅是要完成确定、高效、安全地执行合约字节码的功能,它应该足够通用,能最大化节省开发者的成本,甚至能形成独立的开发者生态。从架构上来说,VM为智能合约提供计算资源和运行容器,区块链的共识、执行模块与VM是完全解耦的。在区块链2.0项目中,我们看到大部分项目将VM作为区块链项目的一个子模块,一同编译进二进制中;Fabric更进一步,链码被编译成独立的程序,运行于独立的docker容器中,通过grpc与节点交互。如此,可将数据与逻辑彻底分离;在未来,VM可能以硬件的形式安装在“矿机”中,通过更底层的如PCIe接口与区块链进行通信。业界的Nervos CKB使用RISC-V实现VM,为演化成硬件模块做准备。架构设计验证层验证层会对合约字节码及传参进行一些验证,包含ABI验证,环境检查与版本检查三个环节。ABI验证:利用合约ABI对用户发送的合约调用及参数进行校验。环境检查:检查虚拟机执行环境是否符合预期检查Config字段。字节码是否合法。exports是否包含apply与memory,以及类型是否正确;是否包含start(被禁用);是否包含import,import的模块是否合法。解释器模块是否ready。版本检查:检查合约版本,选择对应版本的解释器。注入层注入层主要对合约字节码注入一些必要的代码,以及构建相应的执行上下文。Gas MeteringGas metering是用于统计每一个操作所须花费的Gas。原理非常简单:实现Env_api方法useGas。将wasm字节码恢复成易于解析的格式化文本(如JSON)。将useGas注入到格式化文本中将格式化文本重新恢复成wasm字节码。这里有一个值得考虑的问题:**Gas Metering能否放到编译期去做?**在编译器做Gas metering注入的好处是只须要注入一次,节省了执行时的开销。但这样的弊端也很明显:Gas Table本属于区块链协议的一部分,但却被放到合约编译器中,恶意用户只须要更改编译器的Gas Table即可完成作恶,作恶成本大大降低。若Gas Table需要修改,无法再对已部署的旧合约更新Gas Metering,导致新老合约的Gas收费标准不一致。在每次执行时进行一次Gas Metering注入,虽然牺牲了一些执行效率,但换来了Gas灵活变更的特性,这对于不断调整、迭代的公链项目是至关重要的。一种更好的方案,是将Gas Table以合约形式部署,无须硬分叉便可更改Gas Table的参数。Env API 注入Env_api是区块链提供给合约层用于与区块链进行交互的接口。注入原理如下:合约字节码(wast)中包含形如(import env getAddress (func ...))的代码段。意为从env模块中导入getAddress函数。env模块从哪来呢?由虚拟机利用解释器的API构建原生模块,并实现预先设计的Env_api。这里的Env_api都须要用原生语言实现。利用解释器的moduleResolver在执行代码前注入。经以太坊基金会Go-team的gary推荐,这里隆重介绍下EVMC这个项目。它提供了一套虚拟机和客户端之间的通用交互接口。不同的VM只需要实现这些接口,即可为以太坊客户端提供交互功能。如此将客户端与虚拟机实现相互分离,更能够根据实际情况灵活切换底层虚拟机实现。上下文构建我们还需要给合约执行构建合理的上下文环境,提供必要的内部模块和数据以供合约使用,包括:区块链账本实例,提供区块、交易等信息的调用接口。状态数据库实例,提供状态数据的增删改查的调用接口。当前Transaction与Action的相关数据。当前区块高度和区块时间。执行层执行层是虚拟机的核心模块,负责执行合约字节码并返回结果。它必须具备以下几个特性:确定性:即相同入参和上下文,无论在什么设备上运行,何时运行,运行几次,都必须获得相同结果。高效执行:虚拟机的执行时间不大于共识算法给于交易执行的最大时间。停机与回滚:须要有相应停机机制。在执行失败时须要对本次执行涉及的所有状态变更进行回滚。沙箱环境:即保证合约与合约之间、合约与宿主系统之间的资源隔离。能够防备恶意和故障合约的不良影响。Apply执行合约字节码,实际是调用合约代码中的apply函数。合约上下文,包括用户指定调用的合约方法名和对应入参,通过Env_api在实际apply实现中获取,最终调用相应的合约方法。栗子详见系列第二篇。Memory合约除了应导出apply函数外,还须要导出memory对象。memory对象是wasm编译器在合约编译时自动注入,通常会开辟一页内存(64KB) (memory $0 1)。解释器会初始化一个线性字节数组作为内存供wasm使用,wasm与区块链数据交互是依靠内存共享的形式,通过该字节数组进行传递。(这也是为何在Env_api设计里,很多数值的传参是offset与length的组合)Wasm的内存数组是按照| static memory | dynamic memory |的次序划分,static memory中存放编译期的字符串或数组,dynamic memory用于运行期的数据存储,并且可以动态扩容。为了防止dynamic memory无限制地扩容,需要有合理的收费机制与内存分配上限。AssemblyScript提供了一个额外的位于static memory之前的预留空间,称为reserved memory。这使得我们在运行期可以将一些变长数据(如字符串,数组等)以Global的形式导入wasm。这样wasm无须调用Env_api即可直接使用上下文的变量,如发送方、接收方、合约地址、当前调用的合约方法名等。状态存储对VM最本质的需求是对状态存储的需求,这种存储是达成共识的、不可逆的,从而实现了去中心化应用中数据的信任存储。Ethereum1出现的状态爆炸问题给我们敲响了警钟——只收取每一次读写操作的费用,而不收取占用存储的费用,是不合理的。如果不对占用存储收费,则用户可以无限制地占用区块链的稀缺存储资源;且由于没有好的数据清理机制,区块链的状态就会不断增长,即所谓“爆炸”。状态存储付费是很自然想到的方案。如何设计合理的状态存储付费方案,有两个底层逻辑需要考虑:用户应当为占用链上的稀缺存储资源付出成本。这里的成本是广义的,可以是代币价值、机会成本与承担额外风险等形式。状态存储的使用属性最大化,投资属性最小化。须要避免出现用户大量囤积存储资源,提高资源利用率。EOS使用【RAM】来解决状态收费的问题。开发者须要使用代币向系统合约购买RAM,存储状态数据须要消耗对应大小的RAM资源,当数据删除时RAM资源也会相应释放,并且可以卖回给系统拿回代币。但开发者须要承担RAM和代币价值波动风险。如何对RAM定价呢?EOS创新性地引入了Bancor算法对RAM进行模拟市场定价。Bancor算法有两个特点:数字货币价格取决于存储金金额和代币流通量,真实模拟了市场供需关系;人机交易,无须对手盘,这使得“巨鲸”可轻易做多或做空,导致价格波动剧烈。也正因为上面两个特性,EOS主网刚上线时,出现了大量RAM资源被囤积,RAM价格被瞬间拉至高位,又在随后的一周内快速下降,造成了“割开发者韭菜”的情况。V神在2018年曾提出过使用【状态租金】来解决状态爆炸问题。状态租金很像当前云计算服务的商业模式,用户不仅花费购买占用空间大小,还须购买占用时间。对于状态租金方案的具体设计,我们仍然须要考虑以下几个问题:用户体验:当状态出租时间快到期时,如何提醒用户续费?时间到期后状态数据是否立马清除?不同级别的数据是否有区别的对待?(云服务厂商都会提供到期后的赎回期,以防止关键数据被意外删除)支付对象:哪些数据需要支付租金?除了合约的状态数据必然要支付租金以外,账户本身的元数据(balance, nonce等)是否也要付租金?如果需要,时间到期后清零,势必损害用户的资金安全(与区块链保护数字资产的理念相背),同时nonce为0后可能会遭受重放攻击。如果不需要,仍然无法抑制因新账户的创建而产生的状态爆炸问题。定价:链上存储资源的稀缺程度,与区块链的生态价值及当下的市场需求密切相关。如何建立一个合理定价模型便是个非常重要的问题。Ethereum Research中有大神对状态租金的方案进行了细化,引入了激励机制用于租赁过期的发现和确认,并且允许在状态数据删除后申请恢复。Nervos CKB结合了状态买卖和状态租金的长处,利用原生代币代表占用全局状态的权利,且汇率恒定,即1 CKB代表1 Bytes的存储空间。同时巧妙地利用【二级发行】机制为代币持有者(存储空间占有者)设置了【通胀税】,以作为支付给矿工的状态租金。靠通胀收取租金的方式既保留了RAM方案的买断存储空间的使用模式,解决了上面所提到的用户体验的问题,又将定价转移到了通胀部分对应的法币价值,完全由二级市场进行价值发现。但这使得状态空间的上限严格等同于当前代币流通量,在初期可能会限制生态的发展。合约安全我们在第一篇中有提到,合约安全分为编译期安全和执行期安全。本篇主要阐述执行期安全的设计思路。执行期安全也成为运行期安全,主要由VM针对以下两方面提供保障:数据安全:不能随意篡改其他用户或其他合约的状态数据。资源安全:CPU、内存、硬盘资源的分配与回收。数据安全加密数字资产真正实现了人类梦寐以求的“私有财产神圣不可侵犯”,它象征着真正的自由。为了捍卫这份“自由”,数据安全是重中之重。VM需要为以下两个方面提供安全保障:用户数据的安全,即利用密码学算法判断是否有修改状态数据的权限。合约状态数据的隔离,即任何合约都不能直接修改其他合约的状态数据,即使该用户拥有权限。第一个维度很好理解,合约会提供根据用户地址和交易签名进行身份权限审查的功能(甚至可提供基于多密钥对的权限管理),以判断本次合约调用是否有权限修改相应的数据。这也是“私有财产神圣不可侵犯”的根源。第二个维度需要特别解释一下。这里的不能直接修改其他合约的状态数据,是指不能在合约A的方法中直接修改合约B的数据。为什么?因为这会导致状态变更无法追溯,带来不确定性。我们知道,区块链环境中只能通过交易(Transaction)来触发状态变更,交易本身就是状态变更的日志。若允许在合约A中直接修改合约B的状态数据,则这次修改是并未生成相关日志的,使得状态修改无法追溯,与区块链“可追溯”的特性相违背。以太坊中跨合约调用也是没有保留日志的。笔者认为这是因为以太坊合约是不可升级的,一旦部署后地址和代码都是不可变更的,因此可结合交易和代码具体片段来追踪状态变更记录。但以太坊并没有提供相关的索引,这导致对状态修改的记录追踪基本不可能,因此我认为这是一个设计上的重大缺陷。在EOS中,我们看到跨合约调用是生成了新的action,并被加入到原action列表中,在链上保留了状态修改的日志。能否利用静态代码分析的方式确定跨合约的对方地址和相关合约方法,从而追溯到状态变更的细节?当然是可行的,但如果有多层调用(合约A -> 合约B -> ... -> 合约Z),这种方案显然开销是非常大的。尽管以太坊提供了tracer,可以在执行交易的过程中追踪跨合约调用的对象,但如果我想查找导致合约X某状态变更的所有历史操作,上述方案必须遍历并模拟执行所有的历史交易,显然是不可取的。我们认为,跨合约交易正确的做法,是通过内联交易的形式调用合约B的方法从而间接修改合约B数据。即生成一个新的交易来触发目标合约的状态变更。该交易也会应放入区块中,视为由原交易生成的日志。这样可为状态变更保留操作记录,也符合“可追溯”的特征。资源安全智能合约通常运行在由虚拟机提供的沙箱环境,我们需要对其能够使用的资源进行适度的把控。这些资源包括三类:CPU、内存、硬盘。下面我们以QA的形式对涉及到的问题进行解答——CPU资源Q1: 合约运行最大能占用多少个进程,多少个线程?一个;一个或多个。Q2: 是否允许合约内开辟新线程?不允许。合约不应有操作系统级别的调用,而应由虚拟机层来确定性地分配CPU资源(线程数)。Q3:多线程下如何保证线程安全?多线程下,不应通过加锁来保证线程安全,原因是加锁无法保证执行顺序,带来不确定性。正确的做法是在执行前通过静态分析、注解等手段对合约调用进行归类。互斥资源的调用顺序遵循交易发送的顺序;非互斥资源的合约调用可以并行执行。Q4: 如何控制执行时间?利用Gas机制控制合约执行时间(在本系列第一篇已提到),避免过度占用CPU时间。Q5: 如何捕捉错误与处理?合约执行的错误不应导致虚拟机的进程终止,虚拟机应当提供错误捕获和处理的机制。常规的做法时合约运行时的错误以error的形式抛出,虚拟机层捕获后做失败处理,包括终止交易执行、状态回滚、资源回收等。内存资源Q1:合约运行最大能占用多少内存?节点能分配多大的内存给虚拟机,是由矿工决定。这本质上经济学问题:扩大内存分配无疑会增加成本,而这部分提升的执行效率能为矿工带来多少收益。若可用内存过少,部分交易执行失败,可能导致分叉;若可用内存过多,又会造成资源浪费,降低矿工收益。Q2: 内存能否动态扩张?可以,但须要付费。为了防止内存无限制扩张,虚拟机还应对合约的内存占用设置上限。Q3: 如何避免内存泄漏?不应交由合约开发者控制内存回收,虚拟机应当实现GC机制。Q4: 如何避免内存溢出?Wasm虚拟机中内存实则为字节数组,本身带有边界控制,能有效防止内存溢出。磁盘资源Q1: 单个合约最多能够存储多少数据?这也是经济学问题,应该设置合理的硬盘占用计费。Q2: 能否修改其他合约的持久化数据?不能直接修改,因为这会影响到【数据安全】章节中提到的确定性。虚拟机为合约创建的上下文环境中,包含相互隔离的数据空间。可以通过创建新的上下文环境进行数据修改,这样的操作视为一次新的合约调用(保留日志)。Q3: 如何防止未知的数据丢失(如磁盘损毁)?当发生数据丢失时,节点执行合约会得到不同的状态结果,导致区块被认定为非法,区块链无法延长。这里需要区块链系统具备状态一致性的检测机制,在解决硬盘故障后采用同步主链块并重放交易的方式进行恢复。系统合约系统合约是指区块链系统在启动时预先部署的,可升级、可治理的合约,提供如权限控制、资源租赁、代币质押等基础服务。系统合约通常有以下三个特点:公开透明,无暗箱操作。可通过Env_api被用户合约调用。合约通过治理进行代码变更,无须硬分叉。系统合约可采用普通合约的实现方式,并在系统预定的合约地址部署。未来优化方向智能合约的并行执行合约并行执行是提升智能合约执行效率的一大思路。这里的并行执行并不是指单个合约方法内部的并行,而是合约间的并行。实现合约并行执行,我们需要考虑两个重要的问题:如何检测本次合约执行所访问的资源对象?如读写状态数据、读取账户余额等互斥操作。如何做合约执行的合理调度?即哪些合约能够并行执行,哪些必须串行?一种容易想到的思路是这样的:通过静态代码分析检测出合约方法可能访问到的资源,对会访问相同资源的合约调用归为同一个组。每个组的执行可以并行化,组内执行则串行化(根据交易发送顺序)。然而,实际设计时需要考虑的因素就复杂很多:如何设计一个完备的算法,准确地检测合约方法可能访问到的资源(包括跨合约调用中的资源访问)?如何设计一个高效的调度算法,将合约调用准确分组?合约并行执行后所带来的性能提升,是否能够追回以上两个算法所带来的开销?预言机预言机是智能合约获取链外数据的桥梁。这些数据通常由第三方可信数据源提供,如天气数据、赛事数据、数字货币价格等。在传统的互联网应用中,我们可以简单地通过HTTP API获取到这些数据。但在智能合约却不行,原因是HTTP调用通常是异步的,时间不可预估且不具备确定性。因此,需要一个专门的基础设施来为智能合约提供这些链外数据。预言机的设计原则中需要考虑三个要点:获取链外数据并保证数据的真实可用。以确定性、同步的方式被智能合约调用获取。预言机网络本身的安全性和可用性。隐私保护密码学的研究推动了隐私领域的创新。隐私研究主要涉及零知识、多方计算、全同态加密等领域。多方计算 MPC 允许一组人基于他们的输入进行联合计算,而不需要每个人显示其输入值。 例如,Alice 和 Bob 想要知道谁拥有的比特币更多,那么在不需要他们披露自己拥有多少比特币的情况下就能达到这个目的。遗憾的是,目前多方计算的局限性在于它在实践中使用效率极低。全同态加密 (Fully homomorphic encryption) 则允许人们在加密的数据上计算。几十年来,这一直是密码学领域中的一个未解决的问题,直到 2009 年,斯坦福大学博士生克雷格·詹特利 Craig Gentry 使用「理想格」构建了第一个全同态加密方案。如果 Bob 想在 Alice 的数据上执行任意计算,比如训练机器学习模型,同时不必要求 Alice 显示明文数据,理想格加密方案就能派上用场。全同态加密和多方计算一样,目前仍然基本上停留在理论阶段,在实践中的使用效率太低。 

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值