Quinix

(This page might be a bit more readable if you enable 1st-party CSS.)

With output taken care of, one naturally turns to input. But first, let’s take a closer look at our output peripheral, and our approach to peripherals generally, to understand how well it will support asynchronous behaviour like input.

Asynchronous output

Our current iteration of an output peripheral implements synchronous output – upon notifying our peripheral that it should write (by storing 0x1 to the memory-mapped control byte), our VM immediately writes the output and then resumes execution of our program at the next instruction. From the perspective of a program running on the machine, there’s no point at which the output is “in progress”.

Of course, that’s generally not how things work out there in the realm of the real. For instance, if we’re writing to a hard drive made of spinning rust it may take several milliseconds to read from or write to disk. During that time the entire machine doesn’t simply stop and wait for the disk to spin!

To find out whether our approach can handle asynchronous peripherals, first let’s tweak our output peripheral so that it is asynchronous. We’ll update it to wait for the output to flush before marking the peripheral as ready:

const READY = 0x0;
const PRINT = 0x1;
const OutputPeripheral = {
  io: 1,
  shared: 32,
  notify: (view: Uint32Array, address: number) => {
    // Check our IO byte.
    if(view[0] !== PRINT){
      return;
    }

    // Read our shared memory.
    const count = Math.min(view[1], 31);
    const characters = [];
    for(var i = 0; i < count; i++){
      characters.push(
        String.fromCodePoint(view[2 + i]),
      );
    }

    // Print, and wait until the output has flushed before
    // marking the peripheral as ready.
    process.stdout.write(characters.join(''), 'utf-8', () => {
      view[address] = READY;
    });

    // At this point the control byte still contains `0x1`.
    return;
  }
};

Next we’ll update our “Hello, World!” program to busy wait until the write has “completed” – that is, until the peripheral has marked the control byte as “ready” again – before continuing:

; ...
; Write 1 to our peripherals IO address.
constant r6
0x1
store r0 r6

; Wait for the write to complete.
@wait:
constant r0
0x100
load r0 r0
load r0 r0
constant r1 @wait
jnz r0 r1
; ...

Finally, let’s consider how execution will proceed:

Check out this simplified example to see the above play out.

While eventually we might want to rewrite the VM in a language that is more amenable to threaded execution, for now we just want our code to work! One approach we can take is to “release” back to the event loop by setting a zero-length timeout. This will allow the callback to run. A convenient way to accomplish this is by converting our run loop to be an async function, and then using await:

function release(): Promise<void> {
  return new Promise((resolve) => {
    setTimeout(resolve);
  });
}

async function execute(){
  while(true){
    // Fetch, decode, execute, and so on.
    switch(operator){
      // ...
    }

    await release();
  }
}

In practice “releasing” after every single instruction comes with a lot of overhead, and more or less kills what little performance we have. Instead we can release every so often – say, every 100th instruction.

Input

Now that we have understood how asynchronicity can be sufficiently tamed to work in our VM, we can implement input. In keeping with the simplicity of the output peripheral, we’ll implement an input peripheral that reads a single line of text into a shared buffer.

const READY = 0x0;
const INPUT = 0x1;
const InputPeripheral = {
  io: 1,
  shared: 32,
  notify: (view: Uint32Array, address: number) => {
    // Check our IO byte.
    if(view[0] !== INPUT){
      return;
    }

    // Listen for a line of text.
    const listener = (e) => {
      // Write our shared memory.
      const count = Math.min(view[1], 31);
      const characters = [];
      for(var i = 0; i < count; i++){
        characters.push(
          String.fromCodePoint(view[2 + i]),
        );
      }
      process.stdin.off('data');

      // All done.
      view[address] = READY;
    }
    process.stdin.on('data', listener);
  }
};

Once you start listening to stdin your Node process likes to stick around, even after you stop listening to it. So we’ll also need to call process.exit upon halt.