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.
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.