Quinix

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

With an assembler in hand we can write reasonable programs for our virtual machine. Fun! But sadly, our programs are stuck inside of our machine, unable to do much in the way of output (and even less in the way of input). Let’s fix that!

System calls

If you’ve written any lowlevel code before, you might have come across the notion of a “system call”, which you can use to request that operating system do something for you. In Linux for instance you may have seen the write system call (though more likely you’ve simply called printf).

In the olden days of DOS, software interrupt 0x21 was used to make system calls. The arguments were set in the ax register, and the operating system would register an interrupt handler that would use the value of ax to decide what kind of system call to initiate. So in a COM program you might print “Hello, world!” thusly:

org 100h      ; COM executables start at address 0x0100.

mov ah, 9h    ; 0x9 is the "print string" system call.
mov dx, hello ; The address of the string to print is passed in `dx`.
int 21h

mov ah, 4Ch   ; 0x4c is the "exit" system call.
int 21h

hello db 'Hello, world!$'

Right now we don’t have any operating system whatsoever, nor does our CPU support interrupts (and furthermore – what the hell is an interrupt?). Even if we did, our operating system still wouldn’t have a way to interact with the outside world, because our machine still can’t perform input or output. We don’t have any access to “hardware”. So, while we will return to system calls eventually, we’ll set them aside for now.

MMIO + DMA

A common method for allowing programs to access hardware is memory mapped IO. In this approach, special memory addresses are mapped under-the-hood to hardware registers of attached peripherals. For instance, you might have a hardware speaker capable of playing a tone attached at address 0x100. Writing 0x1 could turn the speaker on, and writing 0x0 could turn the speaker off again:

constant r0   ; Peripheral MMIO address.
0x100
constant r1   ; 0x1 means "play".
0x1
store r0 r1   ; Play!

If you are writing a C program you can use the functions inb and outb to read from and write to memory mapped addresses. There are a few quirks when using these instructions – in particular, because you’re working with “real hardware”, you sometimes need to wait for that hardware to actually do what you want it to do! To that end, inb_p and outb_p are provided, which pause to give the underlying hardware a chance to work.

Performing IO via memory-mapped IO works well but can be very slow in hardware, because the processor must mediate the reading and writing of every single byte. “Direct memory access” allows peripherals to access memory directly, without bothering the CPU. While we aren’t worried about performance, it will be convenient to allow our peripherals to share main memory.

For instance, when we eventually implement a “VGA output” peripheral (probably writing to a <canvas> element, that will be cool!) we’ll share a buffer with the peripheral that we use to draw on the screen, and only notify the peripheral when the buffer is ready.

Memory mapped peripherals

Let’s combine MMIO and DMA to provide us a simple interface for peripherals. We’ll allow our peripherals to request a number of “IO” bytes, in the style of memory mapped IO. And we’ll allow our peripherals to request read/write access to a “shared” memory range, in the style of DMA. We’ll control our peripherals by writing to the mapped memory, and pass data back and forth to our peripherals using the shared memory.

type Peripheral = {
  io: number; // MMIO bytes.
  shared: number; // DMA bytes.
  notify: (number[], number) => void;
}

type Map = { [address: numbe]: Peripheral };

function map(memory: number[], peripherals: Peripheral[]): Map {
  const map: Map = {};

  // All peripherals will be mapped into memory
  // contiguously beginning at this address.
  let base = 0x100;

  // Map MMIO bytes for each peripheral.
  peripherals.forEach((peripheral) => {
    for(let i = 0; i < peripheral.io; i++){
      map[base + i] = peripheral;
    }
    base += peripheral.io + peripheral.shared;
  });

  return map;
}

Now, assuming we map our peripherals before we begin execution, we simply update the execution of store to check the peripheral map, and notify any peripheral whose mapped memory has been written. When notifying the peripheral we pass it the address that was written.

  // Map an arbitrary collection of peripherals.
  const peripheralMap = map(peripherals);

  switch(operator){
    // ...
    case STORE:
      memory[registers[dr]] = registers[sr0];

      // Check if this is an MMIO byte. If so, notify the
      // mapped peripheral.
      const peripheral = peripheralMap[registers[dr]];
      if(peripheral){
        peripheral.notify(memory, registers[dr]);
      }
      break;
    // ...
  }

Passing the entirety of machine memory to the peripheraly allows a poorly-written peripheral to write anywhere in memory, even outside of its shared or IO address space. Instead, we can pass a view of the memory that is limited to only those bytes to which the peripheral has been mapped.

While Array instances don’t natively support views, typed arrays do. Since we’re working developing a 32-bit machine in our real implementation we’ll replace our memory storage with an instance of Uint32Array. In the implementation here we use a proxy to wrap the memory.

Our first peripheral: output

For our first trick, let’s finally get some output going! Our peripheral will map 1 “control” byte, along with a 32 byte “buffer”. We’ll use the control byte to notify the peripheral that it should read the buffer and then display it.

A question arises: for strings less than 32 bytes long, how should we inform the peripheral as to where they end? The C programming language and its various relatives use null-terminated strings, which means the end of the string is demarcated by 0x0. This representation, while simple, can be quite error prone – certainly if you have dealt with strings in C you may have had the experience of forgetting to allocate space for the terminator and then printing garbage all over your terminal!

Also, determining the length of a string, which can be frequently necessary, is Ο(n), because one must iterate through the entire string counting characters until a null is encountered. Without care you can find yourself unnecessarily increasing the complexity of algorithms you implement.

Pascal-style, on the other hand, prefixes the string with its length. This makes it easier to avoid overruning the bounds of a string (and allows straightforward bounds-checking), and makes it conveniently Ο(1) to access the length.

For now, we’ll follow the Pascal tradition.

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!
    console.info(characters.join(''));

    // All done.
    view[address] = READY;
  }
};

Hello, world!

Now that we have way to print characters to the “screen”, there’s only one thing left to do. Hello, world!

Ready.
Preview source »

I’ve taken the liberty of tweaking the peripheral to output to the #output element instead of the console, it’s more fun that way!

You’ll notice that we hardcode the address of our peripheral’s mapped memory. This is not great considering we hope to eventually write an operating system that will use perhaps dozens of peripheral! In “real life” there are various protocols that operating systems can use to discover what peripherals are available, including e.g. PCI or ACPI.

So next we’ll need to invent our own approach to informing the program (which will eventually be our operating system) as to which peripherals are available, and what their capabilities are.

Next up: Strings and things »