preface

In the previous article, “The Entire Programming Language in typescript (Pseudo),” we defined an abstract syntax tree with TS. The job after that is the whole back end, generating object code.

But what about compiling to that environment? Next comes the entire virtual machine as the target of compilation

Type and value

The values were defined in the previous section, designing abstract syntax trees. However, for virtual machines, the processing methods and concerns are not the same, so it needs to be processed separately.

Start by defining an enumeration that represents all the types that need to be processed.

export enum LxType {
    nil, int, func
}
Copy the code

There are three types — null, integer, and function. While a value is a tuple, the two-element distribution represents the value’s type and parameters.

One value for an empty type.

type Nil = [LxType.nil, null]
Copy the code

For integers, the argument is a numeric value.

type Int = [LxType.int, number]
Copy the code

In the case of a function, the argument is a prototype of the function.

type Func = [LxType.func, Proto]
Copy the code

Function, including the following properties.

export class Proto {
    numParams: number = 0 // Fixed number of parameters
    code: number[] = [] / / instruction sheet
    slotSize: number = 0 [] // The number of registers
    maxStackSize: number = 0/ / stack space
    constants: LxValue[] = [] / / often scale
    protos: Proto[] = [] // Subfunction prototype table
}
Copy the code

Fixed number of arguments records how many arguments this function passes;

Statements in the body function are compiled into instructions for the virtual machine. The instruction table is a list of these instructions.

The operation of the instruction is carried out on the stack. The stack space indicates the total size of the stack required by the function, facilitating the initialization of the stack.

Values such as int(1) are not included in instructions, which have a finite length. There’s a constant scale;

For a single function, there can be multiple inner functions, and the subfunction prototype table holds the prototypes of these inner functions.

Then all the types that our virtual machine can process can be defined.

type LxValue = Nil | Int | Func
Copy the code

Registers and stacks

In more low-level languages such as C/C ++ or Rust, we often think about whether to put certain data on the heap or on the stack. The heap capacity is large, while the stack access speed is relatively fast. And this stack is the basic structure for processing computation in our virtual machine.

This “virtual stack” is needed to implement both functions, local/temporary variables storage, and operations. This is actually a function of the simulated CPU.

We logically split the stack into two parts. The first part is used to store local/temporary variables, namely registers. The latter part implements the computation part.

So when c = a + b is calculated, the stack changes as follows.

  1. The initial state

2. Push A onto the stack3. Push B on the stack4. Take two numbers from the stack, calculate the result, and push the result4. Write the result in C

In this case, we implement the stack with an array.


export class Stack {
    #slots: LxValue[] = []
    // The top index of the stack. -1 indicates the empty stack
    #top: number = -1

    constructor(size: number) {
        this.#slots = new Array(size).fill(null)
        this.#top = -1
    }
    // Convert relative index to absolute index
    absIndex(idx: number) {
        return idx >= 0 ? idx : (idx + this.#top + 1)}/ / into the stack
    push(val: LxValue) {
        if (this.#top + 1> =this.#slots.length)
            throw new Error('stack: stackoverflow! ')

        this.#top++
        this.#slots[this.#top] = val
    }
    / / out of the stack
    pop() {
        if (this.#top < 0)
            throw new Error('stack: stack is empty! ')

        const val = this.#slots[this.#top]

        if(! val)throw new Error(a)this.#slots[this.#top] = nil()
        this.#top --

        return val
    }
    // Get the value corresponding to the index
    get(idx: number) {
        const absIdx = this.absIndex(idx)
        const val = absIdx >= 0 && absIdx <= this.#top
            ? this.#slots[absIdx]
            : null

        if(! val)throw new Error('out of range! ')

        return val
    }
    // Set the corresponding index value
    set(idx: number, val: LxValue) {
        const absIdx = this.absIndex(idx)
        if (absIdx >= 0 && absIdx <= this.#top)
            this.#slots[absIdx] = val
        else
            throw new Error('stack: invalid index')}// Get the top of the stack
    top() {
        return this.#top
    }
    // Set the top of the stack
    setTop(n:number){
        if (n + 1> =this.#slots.length)
            throw new Error('stack: stackoverflow! ')
        this.#top = n
    }
    // Push the top value of the stack and write it to the corresponding index
    replace(idx: number) {
        const val = this.pop()
        this.set(idx, val)
    }
}
Copy the code

Call frame and stack

Aside from the “stack” of the stack, on the other hand, we often use the concept of “stack” — the call stack. When a function call is executed, a new call frame is pushed onto the call stack. When the function is finished, the call frame is pushed off the stack and continues with the previous function.

First we define a call stack that records the call frames at the top of the stack.

And then to implement the stack operation. If it is already the bottom stack, continuing out of the stack will terminate the program.

The Terminate method is not yet easy to implement, so define it as an abstract method for now. So the LxCallStack is an abstract class.

export abstract class LxCallStack {
    #top: LxCallFrame
    constructor(top:LxCallFrame){
        this.#top = top
    }
    push(frame:LxCallFrame){
        frame.prev = this.#top
        this.#top = frame
    }
    pop(){
        const top = this.#top
        if(top.prev){
            this.#top = top.prev
            return top
        }else{
            this.terminate()
        }
    }
    abstract terminate(): void
}
Copy the code

And in our call frame, we record its last call frame.

The call frame is directly related to the function, and naturally it needs to record the prototype of the current function.

Function instructions are executed on the call frame, so it is also necessary to record the call stack for exit and push.

export class LxCallFrame {
    prev: LxCallStack | null = null
    resultSlot :number
    #proto: Proto
    #stack: LxCallStack
    constructor(proto:Proto,stack: LxCallStack){
        this.#prev = prev
        this.#stack = stack
    }
}
Copy the code

Call frame

In the previous section, we combined call frames with call stacks and designed a prototype call frame.

But we also need to combine the call frame with the stack so that the call frame has the ability to execute instructions.

How to combine? Inheritance!

Since the maximum required stack space is recorded on proto, it happens to be passed to the stack via super().

Also by setTop(), the space reserved for the register is slotSize.

ResIdx records where the results of the call frame need to be written after execution has finished

export class LxCallFrame extends Stack {
    prev: LxCallFrame | null = null
    #stack: LxCallStack
    #resIdx: number
    #proto: Proto

    constructor(proto: Proto, stack: LxCallStack) {
        super(proto.maxStackSize)
        this.setTop(proto.slotSize - 1)
        this.#proto = proto
        this.#stack = stack
        this.#resIdx = resultIdx
    }
}
Copy the code

Since the call frame records the prototype of the corresponding function, it is natural to extend more methods to the stack.

  • Record the current instruction position, and instruction jump, get the method of the next instruction.
export class LxCallFrame extends Stack {
    // ...
    // Instruction index
    #pc = 0
    // Instruction jump
    addPC(n: number) { this.#pc = this.#pc + n }
    // Get the next instruction
    fetch() {
        const ins = this.#proto.code[this.#pc]
        this.#pc = this.#pc + 1
        return ins
    }
}
Copy the code
  • To obtain a constant
export class LxCallFrame extends Stack {
    // ...
    pushConst(idx: number) {
        const s = this.#proto.constants[idx]
        this.push(s)
    }
}
Copy the code
  • Let’s expand on the stack
export class LxCallFrame extends Stack {
    // ...
    // Pushes the value at the specified index to the top of the stack
    pushValue(idx: number) {
        const val = this.get(idx)
        this.push(val)
    }
    // Copy values from one location to another location
    copy(fromIdx: number, toIdx: number) {
        this.pushValue(fromIdx)
        this.replace(toIdx)
    }
}
Copy the code
  • Extend the operation for function calls

Start by pushing the function onto the stack

export class LxCallFrame extends Stack {
    // ...
    // Push the subfunction at the specified index to the top of the stack
    loadProto(idx: number) {
        const proto = this.#proto.protos[idx]
        this.push(func(proto))
    }
}
Copy the code

To execute a function call, before the call is executed, the executing function and the call parameters need to be arranged at the top of the stack. So I need to pass in the number of parameters.

After execution, arguments and functions are off the stack. Function is used to generate new call frames. Parameter is placed on the top of the stack for the new call frame. According to the number of fixed parameters described in the function prototype, if it is insufficient, it will fill in nil, if it is too much, it will drop out.

export class LxCallFrame extends Stack {
    // ...
    // Push the subfunction at the specified index to the top of the stack
    call(arguLength: number) {
        let argus: LxValue[] = []

        while (arguLength-- > 0) {
            argus.push(this.pop())
        }

        argus.reverse()

        const fn = this.pop()

        if (fn[0] !== LxType.func) throw new Error(a)const proto = fn[1]
        const stack = this.#stack
        const nframe = new LxCallFrame(proto,stack)

        for (let i = 0; i < proto.numParams; i++) {
            nframe.set(i,argus[i] ?? [LxType.nil, null])}this.#stack.push(nframe)
    }
}
Copy the code

Returns the result of a function call, pushing it into the corresponding resIdx register of the previous call frame.

export class LxCallFrame extends Stack {
    // ...
    // Push the result at the specified index to the top of the stack
    return(idx: number) {
        const val = this.get(idx)
        if(! val)throw new Error(a)if (this.#prev) {  
            this.prev.push(val)
            this.#stack.pop()
        }
    }
}
Copy the code

Afterword.

We’ve defined a prototype virtual machine above, but this virtual machine has only one shelf for its most important function — executing code.

The next article will add this functionality to the virtual machine.