Skip to content

About

A virtual machine written in c

Resources

Stars

1 star

Watchers

1 watching

Forks

Latest commit

 

History

466 Commits

Folders and files

Repository files navigation

VM32

A 32-bit virtual machine written in C, with its own assembler, linker, disassembler, interactive debugger and a compiler for Ore, a small systems language.

  • vm runs VM32 programs and can trace, disassemble or debug them.
  • vmasm turns assembly source into VM32 binaries or object files. It supports expressions, local labels, macros, structures, conditional assembly, includes and listings.
  • vmld links object files into one program.
  • vmc compiles Ore programs into VM32 binaries. It is written in Ore and runs on the VM.
  • vmc0 compiles Ore as well, without optimizing. It is written in C and compiles vmc in the first place.
  • MiniDos is a small DOS written in Ore: a shell with batch files, redirection and pipes, a file system on up to four of the VM's disks, a full-screen editor, and programs that it loads from there and runs in user mode.

Contents

Building

You need a C11 compiler and a POSIX system.

make          # builds ./vm, ./vmasm, ./vmld, ./vmc0, vmc.bin, which ./vmc runs, and MiniDos with its disk
make test     # builds them and runs the test suite
make clean

You can override CC, CFLAGS and LDFLAGS. For example, for a sanitizer build:

make clean
make CC=clang CFLAGS="-O1 -g -fsanitize=address,undefined" LDFLAGS="-fsanitize=address,undefined"

Quick start

Save this as hello.asm:

; Prints a greeting and the sum 1 + 2 + ... + 10
.text
main:
    LOAD R0, greeting
    SYSCALL #2              ; print string at R0

    LOAD R8, #0             ; sum
    LOAD R9, #10            ; counter
.loop:
    ADD R8, R9
    LOOP R9, .loop          ; decrement R9, jump while not zero

    MOVE R0, R8
    SYSCALL #1              ; print integer
    LOAD R0, #'\n'
    SYSCALL #0              ; print character
    HALT

.data
greeting:
    .asciiz "Hello from VM32! Sum: "

Then assemble and run it:

$ ./vmasm hello.asm -o hello.bin
$ ./vm hello.bin
Hello from VM32! Sum: 55

assembler/examples has more programs. They range from a Fibonacci printer to the first MiniDos, a small command shell with a file system on the disk, which you assemble from MiniDos/main.asm; the MiniDos written in Ore grew out of it. kernel.asm runs a user program under paging, mandelbrot.asm draws with floats, snake.asm is a game for the display and keyboard, and cat.asm prints the files named on its command line:

$ ./vmasm assembler/examples/cat.asm -o cat.bin
$ ./vm cat.bin hello.asm

Running programs

./vm [options] program.bin [arguments...]

Everything after the program path is passed to the program, which reads it with syscall 34.

Option Effect
-b FILE Attach FILE as the image of the next disk drive, up to four times. A missing FILE is created empty
-c FILE Write how often each source line ran to FILE, or to stdout for -
-d Start the interactive debugger
-D Disassemble the program instead of running it
-H COUNT When the program stops with an error, first show the last COUNT instructions and the registers they changed
-j COUNT Compile code into host instructions once COUNT jumps went to it, 1 to 255, or never with 0 (default 32)
-k FILE Take keyboard input from a key script
-L SPEC Print values each time execution reaches a location (see below). Can be given up to 32 times
-m KB Memory size in KB, 128 to 1048576 (default: what the program asks for, or else 1024)
-n COUNT Stop with an error after COUNT instructions
-p Print an execution profile on stderr when the program stops
-s Print display frames as plain text instead of drawing them
-S KB Stack size in KB, at least 4 and less than the memory (default 64)
-t Print each instruction on stderr before it executes
-T TIME Make the clock of syscall 36 show TIME, as 2026-10-01 or 2026-10-01T09:30:00, without moving
-v Print loading and execution statistics on stderr
-x FILE Start the debugger and run its commands from FILE
-h Show help

The exit status is:

  • 0 after HALT.
  • The low 8 bits of the code passed to syscall 30.
  • 1 when the program faults or cannot be loaded.
  • 128 plus the signal number when SIGINT, SIGTERM or SIGHUP stops it.

Outside the debugger, a signal stops the machine before its next instruction, so the report shows where the program was and the devices restore the terminal. A second signal ends vm at once.

A fault stops the machine. The report names the faulting instruction, and its source line too when the binary carries debug information:

$ ./vm fault.bin
vm: error: Division by zero
vm: at 0x0004 fault.asm:5: DIV R8, #0x0

Below that, the report lists the calls and interrupts that led to the fault, innermost first, and folds repeats of the same call:

vm: at 0x0014 <divide+4> fault.asm:16: DIV R8, R9
vm: #1 0x0008 <outer> fault.asm:11: CALL divide
vm: #2 0x0000 <main> fault.asm:7: CALL outer

The VM keeps a shadow stack of the calls and interrupts in flight for this. Frames that the program left without returning, for example by switching stacks, are left out. A call whose code a compiler copied into the caller, and marked with .inline, gets a frame of its own too.

-H COUNT puts the last COUNT instructions before the report, with the registers each one changed:

$ ./vm -H 3 fault.bin
vm: the last 3 instructions:
0x0004 <main+4>             LOAD R9, #0x3            R9=0x00000003
0x0008 <main+8>             ADD R8, R9               R8=0x00000005
0x000C <main+12>            LOAD R10, #0x0
vm: error: Division by zero
vm: at 0x0010 <main+16> fault.asm:12: DIV R8, R10

A trace shows the address, the nearest label and the instruction:

$ ./vm -t hello.bin 2>&1 >/dev/null | head -4
0x0000 <main>               LOAD R0, #0x1000  ; greeting
0x0004 <main+4>             SYSCALL #2
0x0008 <main+8>             LOAD R8, #0x0
0x000C <main+12>            LOAD R9, #0xA

A profile adds up the executed instructions under the closest preceding label:

$ ./vm -p hello.bin
Hello from VM32! Sum: 55
vm: profile of 29 instructions
       count   share  location
          25   86.2%  0x0010 <main.loop>
           4   13.8%  0x0000 <main>

A coverage listing shows every source line that produced code with the number of times it ran, and marks the lines that never ran with #####. A line that became several instructions, such as a macro call or a line named by .loc, ran as often as its instruction that ran most. Each file starts with a summary, and a total follows when there are several files:

$ ./vm -c - count.bin
count.asm: 6 of 7 lines ran
        1:    6: LOAD R8, #3
        3:    8: DEC R8
        3:    9: JNZ count
        1:   10: CMP R8, #0
        1:   11: JZ done
    #####:   12: LOAD R8, #1
        1:   14: HALT

The exit status is 1 when the listing cannot be written.

A logpoint prints registers and memory on stderr each time execution reaches its location, without stopping the program. It is written as LOCATION:ITEM,ITEM..., where the location is a label or an address and each item is one of these:

  • A register, such as R6 or SP.
  • [ADDRESS], the memory at an address that adds up at most one register, numbers and symbols, as in [count], [R8+24] or [buffer+R5].

An item can end in : and a format: x for hex (the default), d for signed and u for unsigned decimal, c for a character, s for a string and f for a float. For memory, b or w reads a byte or a 16-bit word instead of 32 bits. With s, a register is taken as the address of the string, while [ADDRESS] names the string itself:

$ ./vm -L 'find_file:R6:s,[R7+24]:d' shell.bin
log 0x0674 <find_file> R6="a.txt" [R7+24]=0

-D prints the header, then the code with its labels, then a hex dump of the data. A file without a VM32 header is treated as raw code and loaded at address 0.

Compiled code

On x86-64 Linux and macOS, vm compiles the code that jumps go to often into host instructions. Once 32 jumps, or as many as -j says, went to an address, the instructions from there up to one that always goes elsewhere become one block, and blocks go straight on to the blocks they jump to. The machine stays exact: compiled code counts every instruction, so -n stops a program at the same instruction, and it hands an instruction that would fault back to the interpreter, which reports the fault as before. The instructions it does not compile, such as POPCNT, the float instructions and the syscalls other than those for the heap, run in the interpreter too. When the program writes over code that was compiled, vm drops the compiled code and compiles it again once jumps go there often enough.

Whatever has to happen between two instructions makes vm step through them without compiled code: the debugger, -t, -p, -c, -H and logpoints, paging, the trap flag, and the timer or a keyboard interrupt while they are on. -j 0 turns compiling off, and on other hosts vm interprets every instruction.

With compiled code, the Ore programs of the benchmarks run 3.2 to 4.7 times as fast, and vmc compiles itself in 0.28 instead of 0.50 seconds. docs/jit.md describes how it works.

The machine

Registers

There are sixteen 32-bit registers. The special registers also have aliases.

Register Aliases Use
R0 ACC, R0_ACC Accumulator; syscall argument and result
R1 BP, R1_BP Base pointer for stack frames
R2 SP, R2_SP Stack pointer
R3 PC, R3_PC Program counter. While an instruction executes, it already holds the address of the next one
R4 SR, R4_SR Status register (flags)
R5–R7 General purpose; syscall arguments. R5 receives the syscall status
R8–R14 General purpose
R15 LR, R15_LR General purpose; link register by convention

Status register bits:

Bit Flag Meaning
0x01 Z The result was zero
0x02 N Bit 31 of the result was set
0x04 C Unsigned carry out of an addition, or borrow in a subtraction
0x08 O Signed overflow
0x10 I Device interrupts are enabled
0x20 D Reserved
0x40 S Supervisor mode; clear in user mode
0x80 T Trap after every instruction

Control registers

MFCR Rd, NAME reads a control register and MTCR NAME, Rs writes one. A register can also be given by number, as in MFCR R8, #3. Both instructions are privileged.

# Name Meaning Initial value
0 IVTB Address of the interrupt vector table; 0 means there is none 0
1 KSP Stack pointer for interrupts that arrive in user mode; 0 keeps the current SP 0
2 PTB Physical address of the page directory; 0 turns paging off 0
3 FADDR Address that caused the last memory fault 0
4 ECODE Details of the last page fault 0
5 SLO Lowest address the stack may grow to Memory size − the stack size
6 SHI Highest stack address, where SP and BP start Memory size
7 HEAPLO Start of the heap End of the program, rounded up to 16
8 HEAPHI End of the heap SLO

RESET restores these initial values along with the registers.

Memory

Memory is flat, byte-addressed and little-endian. It is 1 MB by default, and -m sets any size from 128 KB to 1 GB. The stack takes 64 KB of it unless -S gives it more or less. Unaligned data accesses are allowed.

Region Contents
From 0, or where vmasm -b put it Code, from .text
From the next page boundary Data, from .data
From the end of the data up to SLO Heap
The top 64 KB, or what -S sets Stack, growing down from the top of memory

Every access is checked:

  • It must lie inside physical memory, after translation when paging is on.
  • An access that touches the heap must stay inside one allocated block and respect that block's protection.
  • Pushing below SLO faults with stack overflow, and popping above SHI with stack underflow.
  • Any stack operation faults while SP lies outside SLO to SHI.

Setting SLO to 0 and SHI to 0xFFFFFFFF turns the stack checks off.

Heap

The heap is a first-fit allocator. Its bookkeeping lives outside VM memory, where programs cannot damage it. It keeps the blocks in a search tree, so allocating and freeing take time that grows with the logarithm of the number of blocks, and the rights of every 8 bytes in a table, so that most accesses are checked without a search.

  • Sizes are rounded up to a multiple of 8, with a minimum of 8 bytes.
  • Every block is preceded by an 8-byte guard gap. Touching it faults, which catches small overruns.
  • New blocks are zero-filled.
  • Freeing a block twice, or freeing an address that is not the start of a block, is an error.
  • Writing HEAPLO or HEAPHI empties the heap. Setting them equal turns the heap off, which leaves that memory to the program.

PROTECT sets a block's permissions: 1 read, 2 write, 4 execute. New blocks get all three. Code only runs from the heap if its block has execute permission.

Interrupts and exceptions

The vector table at IVTB holds one 32-bit handler address for each of the 256 vectors. The entry for vector v is at IVTB + 4*v. An entry of 0, or an IVTB of 0, means there is no handler.

When the CPU enters a handler, it:

  1. Switches to supervisor mode. If it was in user mode and KSP is set, it also switches to the stack at KSP.
  2. Pushes R15 down to R0, so [SP + 4*n] holds Rn. The saved SP and SR are the interrupted values, and the saved PC is where execution resumes.
  3. Clears I and T.
  4. Jumps to the handler.

IRET restores all sixteen registers from that frame, SP included, so it can return to user mode and to a user stack. A handler can read or change the interrupted registers through the frame.

Handlers are entered for four reasons:

  • Software interrupts: INT #v raises vector v at once, whether or not I is set.
  • Device interrupts: these are latched, then delivered before the next instruction once I is set (STI).
  • Exceptions: a fault with a code from 1 to 14 goes to the vector with the same number (see error codes). The registers are put back as they were before the faulting instruction, and the saved PC points at that instruction. A handler can therefore fix the cause and return to retry it, or skip it. The instruction is 8 bytes long when bit 23 of its first word is set, and 4 bytes otherwise. Memory faults leave the address in FADDR.
  • Single-step trap: while T is set, vector 15 is raised after every instruction.

Without a handler, a fault stops the machine. So does a fault while a handler is being entered, such as a stack overflow while the frame is pushed; that is reported as a double fault.

User mode

The CPU starts in supervisor mode, with S set. A kernel enters user mode by executing IRET with a frame whose saved SR has S clear. In user mode:

  • The privileged instructions HALT, CLI, STI, IRET, IN, OUT, RESET, MFCR, MTCR, SYSCALL and PROTECT fault with a privilege violation.
  • Instructions cannot change S, I or T. Writes to SR keep their old values.
  • Pages whose entry lacks the user bit cannot be touched.

User programs reach the kernel through INT. assembler/examples/kernel.asm shows the whole arrangement.

Paging

When PTB is not 0, virtual addresses are translated through two levels of tables with 4 KB pages:

  • Bits 31–22 of an address select an entry in the page directory at PTB.
  • Bits 21–12 select an entry in the page table that directory entry points to.
  • Bits 11–0 are the offset in the page.

An entry holds the physical address of a page table or page in bits 31–12, and flags in its low bits. Directory entries only use P.

Bit Flag Meaning
0x1 P Present
0x2 W Writable
0x4 U Accessible in user mode
0x8 X Executable

A missing entry or a forbidden access raises a page fault, code 14. FADDR holds the virtual address, and ECODE describes the access:

Bit Meaning
1 The page is present but does not allow the access
2 The access was a write
4 The CPU was in user mode
8 The access was an instruction fetch

Syscall buffers and heap blocks are virtual addresses too. The vector table is read with supervisor rights, even when user code is interrupted.

I/O ports

IN Rd, port and OUT port, Rs take the port as an immediate or a register. Both are privileged. Accessing a port that has no device faults.

Port Device IN OUT
0x00 Console Next byte from stdin, or 0 at end of input Writes the low byte to stdout
0x01 Console 0 Writes the low byte to stderr
0x40 Timer interval Current interval Sets the number of instructions between timer interrupts; 0 stops the timer
0x41 Timer vector Current vector Sets the vector raised on expiry (0–255)
0x42 Timer ticks Expirations so far Sets the counter
0x50 Display buffer Buffer address Sets the address of the character buffer and clears the terminal; 0 turns the display off
0x51 Display refresh 0 Draws the cells that changed since the last refresh
0x52 Display columns 80 Ignored
0x53 Display rows 25 Ignored
0x60 Keyboard status Bit 0: a key is waiting. Bit 1: the input has ended Ignored
0x61 Keyboard data The next key, or 0 if none is waiting Ignored
0x62 Keyboard vector Current vector Sets the vector requested while keys wait; 0 turns it off
0x63 Ctrl-C vector Current vector Sets the vector that Ctrl-C requests instead of stopping the machine; 0 turns it off
0x70 Disk sector First sector Sets the first sector of the next transfer
0x71 Disk buffer Buffer address Sets the address of the transfer buffer
0x72 Disk count Sector count Sets the number of sectors per transfer (default 1)
0x73 Disk command Result of the last command 1 reads sectors into memory, 2 writes memory to sectors, 3 sets the size
0x74 Disk size Sectors on the disk, 0 without an image Ignored
0x75 Disk drive The selected drive Selects the drive, 0 to 3, that commands and the size go to

assembler/examples/timer.asm drives a main loop from timer interrupts. The display and the disk take physical addresses, which do not go through paging.

Display

The display shows an 80×25 buffer of cells in memory. A cell is two bytes: the character, then an attribute with the foreground color in the low nibble and the background color in the high nibble. Colors 0 to 7 are black, red, green, yellow, blue, magenta, cyan and white, and 8 to 15 are their bright versions. Characters outside printable ASCII show as spaces.

Setting the buffer address switches the terminal to its alternate screen, clears it and hides the cursor. Each write to the refresh port then draws the cells that changed with ANSI escape sequences, so a program updates its buffer and refreshes once per frame. When the display is turned off or the program ends, the cursor reappears and the terminal shows again what it showed before; a terminal without an alternate screen keeps the display, with the cursor below it.

With vm -s, a refresh prints the characters as plain text instead, when they changed since the last frame. A header line gives the number of the refresh and the instructions executed so far, and trailing spaces are left out:

$ ./vm -s snake.bin 0 < keys.txt | tail -26 | head -4
--- refresh 31, instruction 45700 ---
 SNAKE   arrows or WASD steer, q quits                              score:    1
################################################################################
#                                                                              #

Keyboard

The keyboard reads stdin directly. The first access to one of its ports switches a terminal to unbuffered input without echo, and vm restores the terminal when the program ends. Ctrl-C stops the machine, while the other control keys, Ctrl-Z, Ctrl-S and Ctrl-Q among them, reach the program. A program that writes a vector to port 0x63 gets that interrupt for Ctrl-C instead, and only a second Ctrl-C before it has entered the handler stops the machine. A Ctrl-C byte from a pipe or a key script requests the interrupt as well, once the program has read the keys before it. Keys are the bytes typed, except for the arrow and editing keys:

Key Code
Up 0x100
Down 0x101
Right 0x102
Left 0x103
Home 0x104
End 0x105
Insert 0x106
Delete 0x107
Page Up 0x108
Page Down 0x109

Other escape sequences are dropped, and an escape that is not followed by the rest of a sequence is the Escape key, 0x1B. Up to 64 keys wait in a queue. Reading the status or the data port checks for new input. While the vector port holds a vector, the keyboard also checks every 10000 instructions and requests the interrupt for as long as keys wait.

When stdin is a file or a pipe, its bytes arrive as keys and the status reports the end of input. Reading the console as well can split the input, because the console reads ahead into a buffer.

vm -k FILE takes the keys from a key script instead, which makes runs of interactive programs repeatable. Each line holds an instruction count, or +N for N instructions after the line before, then a space and the keys. \n, \r, \t, \e, \\ and \xNN stand for single bytes, and lines that are empty or start with # are skipped. The keys become available once the VM has executed that many instructions, and the input ends after the last line:

# Up arrow at instruction 50000, then q 20000 instructions later
50000 \e[A
+20000 q

With a script, the keyboard leaves the terminal and stdin alone, and looks for keys after every instruction, so an interrupt comes at the exact count. As only instructions count, the sleep syscall returns at once.

Disk

vm -b FILE attaches a disk image of 512-byte sectors as drive 0, and each further -b the next drive, up to drive 3; a partial sector at the end of FILE is left out. A FILE that does not exist is created empty, as a disk without sectors. Port 0x75 selects the drive that the commands and the size port go to, while the sector, buffer and count ports serve all drives.

Writing a command to port 0x73 runs it on the selected drive. Commands 1 and 2 move the given number of sectors, starting at the first sector, between the image and the buffer. Command 3 makes the disk as many sectors long as the count says, up to 4,194,304 (2 GB), adding zeros to the image or cutting off its end. A command is done when OUT returns, and reading the port gives the result:

Result Meaning
0 Done
1 No disk image is attached
2 The sectors reach past the end of the disk
3 The buffer reaches past the end of memory
4 Unknown command
5 The image is read-only
6 The host reported an error

An image that vm may not write is attached read-only. A transfer of no sectors does nothing, but its result of 0 or 1 tells whether the drive has an image, even an empty one. The MiniDos example in assembly keeps up to 32 files of 4 KB on a disk of at least 259 sectors:

$ dd if=/dev/zero of=disk.img bs=512 count=300
$ ./vmasm assembler/examples/MiniDos/main.asm -o shell.bin
$ ./vm -b disk.img shell.bin

Its format command creates an empty file system, and dir, type, write, append and del work with the files.

Instruction set

Encoding

Each instruction is one or two little-endian 32-bit words at a 4-byte aligned address. Jumping to an unaligned address faults.

 31        24 23    20 19    16 15    12 11                0
+------------+--------+--------+--------+-------------------+
|   opcode   |  mode  |   r1   |   r2   |     immediate     |
+------------+--------+--------+--------+-------------------+

In the IMM, MEM, STK and BAS modes, the r2 field holds the upper 4 bits of a 16-bit immediate. When bit 3 of the mode is set, a second word follows and holds the whole 32-bit immediate or displacement. The assembler adds that extension word whenever a value does not fit the first word. The top three bits of the opcode select the instruction group.

Addressing modes

Mode Syntax Operand Range in the first word
IMM #expr or expr The value itself, sign-extended −32768 to 32767
REG R5 The register
MEM [expr] Memory at an absolute address 0 to 0xFFFF
REGM [R5] Memory at the address in a register
IDX [R5+expr], [R5-expr] Register plus displacement −2048 to 2047
STK [SP], [SP+expr] SP plus displacement −32768 to 32767
BAS [BP], [BP-expr] BP plus displacement −32768 to 32767

Values outside these ranges go in an extension word, which holds any 32-bit value.

A bare label is an immediate. LOAD R0, msg loads the address of msg, while LOAD R0, [msg] loads the word stored there.

The tables below use three operand kinds:

  • src accepts every mode.
  • addr accepts only the memory modes.
  • val accepts IMM or REG.

Data transfer

Instruction Opcode Operation
NOP 0x00 Does nothing
LOAD Rd, src 0x01 Rd = src (memory operands are read as 32 bits)
STORE Rs, addr 0x02 Stores Rs
MOVE Rd, Rs 0x03 Rd = Rs
LOADB Rd, src 0x04 Rd = src, zero-extended from 8 bits
STOREB Rs, addr 0x05 Stores the low byte of Rs
LOADW Rd, src 0x06 Rd = src, zero-extended from 16 bits
STOREW Rs, addr 0x07 Stores the low 16 bits of Rs
LEA Rd, addr 0x08 Rd = the effective address

LOAD Rd, #value accepts any 32-bit constant, including negative numbers and floats.

Arithmetic

Instruction Opcode Operation Flags
ADD Rd, src 0x20 Rd += src Z N C O
SUB Rd, src 0x21 Rd −= src Z N C O
MUL Rd, src 0x22 Rd *= src, keeping the low 32 bits. O is set when the unsigned product needed more Z N O
DIV Rd, src 0x23 Unsigned division Z N
MOD Rd, src 0x24 Unsigned remainder Z N
INC Rd 0x25 Rd += 1 Z N O
DEC Rd 0x26 Rd −= 1 Z N O
NEG Rd 0x27 Rd = −Rd Z N O
CMP Rd, src 0x28 Sets the flags of Rd − src Z N C O
ADDC Rd, src 0x2A Rd += src + C Z N C O
SUBC Rd, src 0x2B Rd −= src + C Z N C O
IDIV Rd, src 0x2C Signed division, rounding toward zero Z N
IMOD Rd, src 0x2D Signed remainder, with the sign of the dividend Z N
MULH Rd, src 0x2E Rd = the upper 32 bits of the signed 64-bit product Z N
UMULH Rd, src 0x2F Rd = the upper 32 bits of the unsigned 64-bit product Z N

Division by zero faults. So does signed division of −2147483648 by −1.

Floating point

Floats are IEEE single-precision values kept in the general registers. The assembler writes them as literals such as #1.5 or #-2e-3, and syscall 7 prints them.

Instruction Opcode Operation Flags
FADD Rd, src 0x30 Rd += src Z N; clears C and O
FSUB Rd, src 0x31 Rd −= src Z N; clears C and O
FMUL Rd, src 0x32 Rd *= src Z N; clears C and O
FDIV Rd, src 0x33 Rd /= src. Dividing by zero gives an infinity or NaN Z N; clears C and O
FCMP Rd, src 0x34 Compares Rd with src Z C O; clears N
FSQRT Rd, src 0x35 Rd = the square root of src Z N; clears C and O
FNEG Rd 0x36 Flips the sign of Rd Z N; clears C and O
FABS Rd 0x37 Clears the sign of Rd Z N; clears C and O
ITOF Rd, src 0x38 Rd = the signed integer src as a float Z N; clears C and O
FTOI Rd, src 0x39 Rd = src truncated to a signed integer Z N O; clears C
  • Comparing: FCMP sets Z when the values are equal and C when Rd is smaller, so the unsigned jumps JB, JBE, JA and JAE apply. If either value is NaN, the comparison is unordered and sets Z, C and O.
  • Converting: FTOI gives −2147483648 and sets O for NaN and for values outside the 32-bit range.

Logic and shifts

Instruction Opcode Operation Flags
AND Rd, src 0x40 Rd &= src Z N; clears C and O
OR Rd, src 0x41 Rd |= src Z N; clears C and O
XOR Rd, src 0x42 Rd ^= src Z N; clears C and O
NOT Rd 0x43 Rd = ~Rd Z N
SHL Rd, src 0x44 Shift left Z N C
SHR Rd, src 0x45 Logical shift right Z N C
SAR Rd, src 0x46 Arithmetic shift right Z N C
ROL Rd, src 0x47 Rotate left Z N C
ROR Rd, src 0x48 Rotate right Z N C
TEST Rd, src 0x49 Sets the flags of Rd & src Z N; clears C and O
POPCNT Rd, src 0x4A Rd = the number of set bits in src Z N; clears C and O
CLZ Rd, src 0x4B Rd = the number of leading zero bits in src, 32 for 0 Z N; clears C and O
CTZ Rd, src 0x4C Rd = the number of trailing zero bits in src, 32 for 0 Z N; clears C and O
BSWAP Rd 0x4D Reverses the byte order of Rd Z N
SETcc Rd 0x4E Rd = 1 if condition cc holds, otherwise 0 None

SETcc takes every condition of the conditional jumps, under all their names: SETZ, SETNE, SETL, SETAE and so on.

Shifts and rotates use the low 5 bits of the operand as the count.

  • After a shift, C holds the last bit shifted out.
  • After a rotate, C holds the bit that wrapped around.
  • A count of 0 leaves C unchanged.

Control flow

Instruction Opcode Operation
JMP target 0x60 Jumps
Jcc target 0x61–0x68, 0x70–0x74 Jumps if the condition holds (see the table below)
CALL target 0x6A Pushes the return address and jumps
RET [#n] 0x6B Pops the return address, then drops n bytes of arguments
SYSCALL #n 0x6C Performs system call n
LOOP Rc, target 0x6F Rc −= 1, then jumps if Rc ≠ 0. Flags are not changed

A target can be:

  • A label or address, as in JMP loop. It is stored as an offset from the next instruction, so the code runs at any address.
  • A register, as in JMP R8.
  • A memory operand that holds the address, as in JMP [table+8].
Jump Opcode Also written Taken when
JZ 0x61 JE Z
JNZ 0x62 JNE not Z
JN 0x63 N
JP 0x64 not N and not Z
JO 0x65 O
JC 0x66 JB, JNAE C (unsigned below)
JBE 0x67 JNA C or Z
JA 0x68 JNBE not C and not Z
JL 0x70 JNGE N ≠ O (signed less)
JGE 0x71 JNL N = O
JLE 0x72 JNG Z, or N ≠ O
JG 0x73 JNLE not Z and N = O
JAE 0x74 JNB, JNC not C

Stack

Instruction Opcode Operation
PUSH src 0x80 SP −= 4, then stores src at SP
POP Rd 0x81 Loads Rd from SP, then SP += 4
PUSHF 0x82 Pushes SR
POPF 0x83 Pops SR
PUSHA 0x84 Pushes R15 down to R0, so [SP + 4*n] holds Rn. The saved SP is its value before PUSHA
POPA 0x85 Restores the registers saved by PUSHA, except SP and PC
ENTER #n 0x86 Pushes BP, sets BP = SP, then reserves n bytes
LEAVE 0x87 Sets SP = BP, then pops BP
PUSHM Ra, Rb 0x88 Pushes Rb down to Ra as PUSHA does, so [SP + 4*(n−a)] holds Rn. vmasm wants Ra first
POPM Ra, Rb 0x89 Restores the registers saved by PUSHM Ra, Rb, except SP and PC

After CALL and ENTER, [BP+4] is the return address and [BP+8] is the last argument pushed before the call.

System

Instruction Opcode Operation
HALT 0xA0 Stops the program
INT #v 0xA1 Raises interrupt v (0–255)
CLI 0xA2 Clears I
STI 0xA3 Sets I
IRET 0xA4 Returns from an interrupt handler
IN Rd, val 0xA5 Reads a port
OUT val, Rs 0xA6 Writes Rs to a port
CPUID 0xA7 Reports machine information, selected by R0 (see the table below)
RESET 0xA8 Restores the initial registers and control registers, empties the heap and jumps to the entry point. Memory is kept
DEBUG 0xA9 A breakpoint under vm -d. Does nothing otherwise
MFCR Rd, NAME 0xAA Reads a control register
MTCR NAME, Rs 0xAB Writes a control register

HALT, CLI, STI, IRET, IN, OUT, RESET, MFCR and MTCR are privileged, as are SYSCALL and PROTECT.

R0 CPUID result
0 R0 = highest function (4). R5 and R6 = the vendor string "VM32CPU"
1 R0 = version, 0x00020000. R5 and R6 = feature bits. In R6, bits 1 to 4 stand for the timer, display, keyboard and disk
2 R0 = memory size. R5 = page size. R6 = stack size
3 R0 = number of instructions. R5 = mask of addressing modes. R6 = mask of instruction groups
4 R0 = instructions executed. R5 = 2 under the debugger, plus 4 while I is set, 8 in supervisor mode and 16 while paging is on. R6 = the last error code

Memory management

Instruction Opcode Operation
ALLOC Rd, val 0xC0 Rd = the address of a new zero-filled block of val bytes. Faults if the heap is exhausted
FREE Rs 0xC1 Frees the block at Rs
MEMCPY Rd, Rs, size 0xC2 Copies size bytes from Rs to Rd. The ranges may overlap
MEMSET Rd, Rv, size 0xC3 Fills size bytes at Rd with the low byte of Rv
PROTECT Ra, val 0xC4 Sets the permissions of the block at Ra

size is an immediate or a register. Syscalls 20 to 22 and 24 do the same jobs, but they report failures in R5 instead of faulting.

Assembly language

label:  MNEMONIC operand, operand   ; comment
  • Each line holds at most one statement, which can follow a label. Comments start with ;.
  • Mnemonics, registers and directives are case-insensitive. Symbols are case-sensitive.
  • A label that starts with a dot is local to the global label before it. For example, .loop after main: defines main.loop, and other code can reach it under that full name.
  • Instructions go in .text, which starts at 0x0000 unless -b gives another address. Data goes in .data, which starts on the first page boundary after the code. You can switch between the two sections as often as you like.
  • An instruction gets an extension word when an operand needs one. For operands that refer to later symbols, the assembler repeats its layout pass until no address moves.

Expressions

Operands and directive arguments are integer expressions. An expression can contain:

  • Numbers: 42, 0x2A, 0b101010, 0o52, or C-style octal such as 052.
  • Floats: decimal numbers with a fraction or an exponent, such as 1.5 or 3e8, stand for the bits of a single-precision float. They can take a sign, but no other operator.
  • Characters: 'A', '\n'.
  • Symbols.
  • $: the address of the current statement.
  • C operators: these use C precedence, from tightest to loosest:
Operators
unary - + ~ !
* / %
+ -
<< >>
< <= > >=
== !=
&
^
|
&&
||

Comparisons and logical operators yield 1 or 0. Strings and characters accept the escapes \n \t \r \0 \a \b \f \v \e \\ \' \" and \xHH.

Directives

Directive Effect
.text, .data Switch section
.byte v, ... 8-bit values. Strings are allowed too
.word v, ... 16-bit values
.dword v, ... 32-bit values
.float v, ... Single-precision floats. Integers are converted
.ascii "s", ... String bytes
.asciiz "s", ... or .string NUL-terminated strings
.space n[, fill] or .skip n bytes of fill (default 0)
.align n Pad to a multiple of n, a power of two up to 4096
.org offset Pad forward to an offset from the start of the current section
.equ NAME, expr or .set Define a constant. A constant cannot be redefined
.struct NAME ... .ends Define a structure
.rept count ... .endr Repeat the lines in between count times (see below)
.entry expr Start execution here instead of at 0x0000
.global name, ... Export symbols from an object file to the others it is linked with
.extern name, ... Use symbols that another object file exports
.error "message" Stop assembly with this message. Useful inside .if
.include "file" Insert a file. It is searched for next to the including file, then in -I directories
.incbin "file"[, offset[, length]] Insert the bytes of a file, found like an include, or length of them from offset on
.loc "file", line[, "text"] Attribute the code and labels that follow to a line of another source file (see below)
.inline "file", line ... .endinline Mark code copied from a function into a call of it at a line of another source file (see below)
.macro ... .endm Define a macro
.if, .ifdef, .ifndef, .else, .endif Conditional assembly
.library Make the rest of the file a library, of which a program only gets what it uses

A value can be written signed or unsigned, but it must fit its width. For example, .byte accepts −128 to 255.

The arguments of .space, .align, .org and .if decide the layout. They may only use symbols defined earlier, and must not depend on labels whose addresses move when instructions grow.

The count of .rept is read along with the source, before any label exists, so it may only use numbers, -D constants and .equ constants defined earlier from those. Blocks can be nested, and a block with labels in it defines them once per repetition, which clashes; macros with \@ avoid that.

.loc is for compilers that write assembly. Up to the next .loc, debug information names the line of the original source instead of the line of assembly, so fault reports, backtraces, the debugger and coverage listings show where the code came from:

    .loc "fact.c", 3, "return 1 / (n - 1);"
    LOAD R5, #1
    SUB R0, #1
    DIV R5, R0
vm: error: Division by zero
vm: at 0x0024 <fact+24> fact.c:3: DIV R5, R0

The file name is recorded as written. Without the text, vmasm takes the line from the file when it can read it. The text is what coverage listings show, and what the debugger shows when it cannot open the file.

.inline and .endinline enclose code that a compiler copied from a function into a call of it, and name the line of the call. Fault reports, backtraces and the debugger's bt show that call as a frame, marked inlined, at the start of the copy and below the frames of what the copy holds. Copies nest up to 16 deep:

    .inline "shapes.c", 10
    .inline "shapes.c", 5
    .loc "shapes.c", 2, "    return a / b;"
    MOVE R6, R0
    DIV R6, R5
    .endinline
    .loc "shapes.c", 5, "    return ratio(a, b) * a;"
    MUL R6, R0
    .endinline
vm: error: Division by zero
vm: at 0x0024 <area+16> shapes.c:2: DIV R6, R5
vm: #1 0x0020 <area+12> shapes.c:5: inlined
vm: #2 0x0020 <area+12> shapes.c:10: inlined
vm: #3 0x0008 <main+8> main.asm:10: CALL area

Structures

Between .struct NAME and .ends, a label names the offset of a field as NAME.label, and the data directives .byte, .word, .dword, .float, .ascii, .asciiz, .space and .align only reserve room. After .ends, NAME is the size of the whole structure. Nothing is emitted, and instructions are not allowed inside:

.struct Entry
name:   .space 24
size:   .dword 0
.ends                           ; Entry.name = 0, Entry.size = 24, Entry = 28

    LOAD R9, [R8 + Entry.size]
entries:
    .space Entry * 32

Macros

.macro PRINT_NUMBER reg
    CMP \reg, #0
    JNZ .number\@
    LOAD R0, zero_text
    SYSCALL #2
    JMP .done\@
.number\@:
    MOVE R0, \reg
    SYSCALL #1
.done\@:
.endm

    PRINT_NUMBER R8
  • Parameters: separated by commas. In the body, \name is replaced by the argument everywhere except inside quotes.
  • Defaults: name=text gives a parameter a default, which replaces an argument that is left out at the end or given empty. .macro SHOW value, base=10 can be invoked as SHOW 255 or SHOW 255, 16.
  • \@: replaced by a number unique to each expansion, which keeps labels from clashing.
  • Arguments: split at commas that are not inside quotes, brackets or parentheses.
  • Order: a macro must be defined before it is used.
  • Nesting: macros can invoke other macros, up to 32 levels deep, but a macro cannot be defined inside another one.

Conditional assembly

.ifdef VERBOSE
    DEBUG
.endif

.if LEVEL >= 2 && MODE == 3
    LOAD R8, #1
.else
    LOAD R8, #0
.endif
  • .if evaluates an expression, which must be known when the line is reached.
  • .ifdef and .ifndef test whether a symbol is defined by an earlier line or on the command line.
  • Blocks can be nested.
  • vmasm -D NAME[=VALUE] defines constants from the command line.

Includes and macro definitions are processed even inside a false block. The included file must therefore exist, and macros defined there are available either way.

Libraries

After .library, the code or data from one label to the next is a unit of its own, and the program only gets the units that it uses. A file of routines that programs include costs each program only the routines it calls:

    .library
square:                 ; kept by a CALL square elsewhere
    MUL R0, R0
    RET
cube:                   ; left out while nothing refers to it
    MOVE R5, R0
    MUL R0, R0
    MUL R0, R5
    RET
  • Used: a unit is kept when one of its labels or constants is exported with .global, or named by .entry, by code or data outside libraries or by a unit that is kept. Without .entry, the unit where the code starts is kept too.
  • Running on: code that does not end with JMP, RET, IRET or HALT can run on into the next unit, which is then kept with it. A HALT after a syscall that does not return, such as Exit, tells vmasm that it does not.
  • Data is only kept for the label it follows, so code must not reach past a label into the data of the next.
  • Directives other than data and .equ stay even in a unit that is left out, so .align still pads.
  • A unit that is left out may refer to symbols that nothing defines.

The runtime of Ore programs and std/cpu are libraries.

vmasm

./vmasm [options] input.asm
Option Effect
-o FILE Output file (default: the input with a .bin extension, or .o with -c)
-c Write an object file for vmld instead of a program
-b ADDR Put the code at ADDR, a multiple of 4096 below 0x80000000, for a program that shares memory with another one at 0
-m KB Ask for KB of memory, 128 to 1048576, which vm then gives the program unless its own -m says otherwise
-l FILE Write a listing: line number, address, encoded bytes and source. Lines from macros are marked with +
-I DIR Also search DIR for included files
-D NAME[=VALUE] Define a constant, 1 unless a value is given
-s Print the symbol table
-S Leave out debug information
-W Warn when a call or syscall overwrites a register value that is read afterwards
-h Show help

-I and -D also accept their value attached, as in -DNAME.

-W follows values through the registers. For each routine that is called directly, it works out which registers the routine reads, which it changes, and which of those it returns: the ones that hold a value it computed on every way out, rather than one it only handed to another call, and the ones it reads and then changes. It then follows the code from every label and warns where a register is read after a call or syscall overwrote a value that nothing had read:

$ ./vmasm -W minidos.asm
disk.asm:80: warning: R6 set at line 74 is overwritten by CALL load_directory at line 77 before it is read here

A value counts as overwritten when the call changes the register without returning it, or when the program set the register itself and the call returns a result there, as every syscall does with its status in R5. Routines that jump through registers or move SP other than by constants are taken to read and change everything, so they cause no warnings. The check is a heuristic: it can miss mistakes, and it warns about a register that a routine returns on only some paths.

By default, a binary includes the symbols and source lines that the disassembler, the debugger and fault reports use. Errors are reported as file:line: error: message, and vmasm then exits with status 1.

Linking

A program can be split into files that are assembled on their own with vmasm -c and linked with vmld. A file exports the labels and constants that others may use with .global, and declares the ones it uses from others with .extern:

; main.asm                          ; print.asm
.extern print_line                  .global print_line
.text                               .text
main:                               print_line:
    LOAD R0, greeting                   SYSCALL #2
    CALL print_line                     LOAD R0, #'\n'
    HALT                                SYSCALL #0
.data                                   RET
greeting:
    .asciiz "hello"
$ ./vmasm -c main.asm
$ ./vmasm -c print.asm
$ ./vmld main.o print.o -o hello.bin
$ ./vm hello.bin
hello

In an object file, labels are offsets into their section until the linker places it, so an address can only be stored where the linker can fill it in:

  • An instruction operand, which then takes the extension word. Jumps to labels in the same file's code stay as they are.
  • A .dword value. .byte and .word cannot hold an address.
  • An .equ constant, which can in turn be exported.

An address may be added to or subtracted from a number, and two addresses in the same section may be subtracted from each other. Every other use needs a constant, including .space, .align, .org and .if. Symbols that a file uses have to be defined in it or declared with .extern.

vmld places the code of all files from address 0, or the address given with -b, in the order they are given, and their data from the page after the code, each aligned to the largest .align of its section. The program starts at the .entry of the one file that has one, at the exported code label given with -e, or at the start of the code. Symbols and source lines of all files end up in the program, so fault reports, backtraces and the debugger work across files.

Option Effect
-o FILE Output file (default: the first object file with a .bin extension)
-e NAME Start at the exported code label NAME
-b ADDR Put the code at ADDR, a multiple of 4096 below 0x80000000
-m KB Ask for KB of memory, as vmasm -m does
-M Print where each file's sections and exported symbols went
-S Leave out debug information
-h Show help

Linking fails, and names the file, when a symbol is undefined, two files export the same name, or more than one file sets an entry point. vm refuses to run an object file.

Ore

docs/language.md describes the language. vmc compiles a program, meaning its main module and every module that imports, into a binary:

$ ./vmc primes.ore
$ ./vm primes.bin
168 primes below 1000, the largest is 997
Usage: vmc [options] program.ore [file.asm...]
  -o FILE   write the binary to FILE (default: the program with .bin)
  -S        write the assembly instead, to FILE or the program with .asm
  -g0       leave out the .loc lines and the binary's debug information
  -b ADDR   put the program at ADDR instead of 0, as vmasm -b does
  -m KB     ask for KB of memory, as vmasm -m does
  -I DIR    look for imported modules in DIR as well
  -L DIR    take the runtime and the standard library from DIR

Two compilers take these options:

  • vmc is written in Ore, in ore/compiler, and optimizes. It copies the body of a function into its calls where the call costs about as much as the body, where the call runs in a loop and the body is small, and where it is the only call, which leaves the function unused; a backtrace still shows such a call. It keeps scalar locals and parameters, the pointer and length of slices, and values that wait while another is computed in registers where that saves more than it costs, with locals that live at different times sharing one, uses variables, fields and slice lengths as operands where instructions can take them, checks a pointer for null once until something changes it, and not at all for a pointer parameter that every call passes an address, new memory or another such pointer. It leaves out the bounds checks of indexes that a counting loop or their type keeps in range, and of a local index that an earlier check against the same slice or array already covers while neither changed, branches on conditions without first making values of them, makes the value of && and || once, after branching on their operands, tests the condition of a loop after each round instead of jumping back to it, computes arithmetic that a loop does not change in front of it, keeps a multiple of the counter of a loop in a local that steps along with the counter instead of multiplying, and gives a function that keeps no locals in memory no frame. A function saves the registers it uses with one PUSHM and restores them with one POPM in each of its returns, and a local that a comparison loaded into R0 is not loaded again right after it. Unsigned division and remainder by a power of two become a shift and a mask, x & y and x % 2^k compared with 0 test bits instead of computing a value, and a switch with at least four values close together jumps through a table of addresses. Its code executes less than half as many instructions as vmc0's and is a third smaller. make builds it in two stages: vmc0 compiles it into vmc1.bin, which compiles it again into vmc.bin, and vmc.bin compiles itself into the very same file. The vmc script runs vmc.bin on the VM with 256 MB of memory and an 8 MB stack and assembles the program it writes with vmasm.

  • vmc0 is written in C, in ore/bootstrap, and exists to compile vmc when there is no vmc.bin yet. Its code keeps every local in the frame. It runs natively, so it compiles much faster: vmc takes 0.3 seconds to turn its own 9,400 lines into assembly, vmc0 0.05 seconds.

  • How it compiles: the compiler writes the whole program as one assembly file, with only the functions and globals that main reaches, directly or through others, and those that the program's assembly names: a program that calls io.write gets that function and not the rest of std/io. That file includes ore/lib/runtime.asm, any assembly files named on the command line, and the assembly that imported modules bring along for their extern fn functions. It is then assembled with the vmasm next to the compiler, which leaves out the routines of the runtime and std/cpu that the program does not use, since they are libraries.

  • Standard library: import "std/io" and the other standard modules come from ore/lib/std.

  • Source lines: every statement carries a .loc line, so fault reports, backtraces, the debugger and coverage listings show Ore source lines. Only with -S do they carry the text of the line too, for people to read; otherwise vmasm reads it from the source file. -g0 leaves them out, and with them the symbols and source lines of the binary, which then holds only its code and data at a tenth of the size or less. Faults in it are reported by address only.

  • Runtime errors, such as an index out of bounds, a null pointer or a failed assert, stop the program with a message, reported at the Ore line that failed. For the digits.ore example in the language description:

$ ./vm digits.bin
vm: error: index 10 is out of bounds for length 10
vm: at 0x00EC <digits.main+32> digits.ore:2: CALL rt.index_error
vm: #1 0x00D0 <digits.main+4> digits.ore:6: inlined

vmc copied digit into main, so the call shows as inlined; a build of vmc0 shows CALL digits.digit there instead.

The error messages of both compilers name the file and line of the first problem, and compiling stops there.

MiniDos

MiniDos is a small DOS written in Ore, in ore/minidos. It keeps its files on up to four disks and has 42 commands with /? help, batch files, pipes, a line editor with a history and completion of file names, a clock and a full-screen editor, EDIT. It lives above the first megabyte, built with vmc -b 0x100000, and runs programs below it in user mode, so any program vmc builds runs under it unchanged: their syscalls arrive at MiniDos as privilege violations, and it serves them from its own disks and console.

vm creates a disk image that does not exist yet, and MiniDos offers to format it and to put its programs SORT, FIND and FC on it:

$ ./vm -b work.img minidos.bin

Starting MiniDos...

The disk in drive A is new and not formatted yet.
Format it now (Y/N)?Y
Size of the new disk in K, or in M with an M after it (ENTER for 4M)?
Formatting 4,096K
Format complete.
System transferred

Without -b, MiniDos keeps its files on a disk in memory until it stops. make builds minidos.bin and minidos.img, a 4 MB disk formatted that way, which it builds anew whenever MiniDos changes.

Syscalls

SYSCALL #n takes its arguments in R0, R5, R6 and R7 and returns its result in R0. Every syscall also sets R5 to a status: 0 on success, otherwise one of the error codes.

  • Console syscalls fault when given an invalid address.
  • File and memory syscalls report problems in R5 instead, and the program keeps running.
  • An unknown syscall number faults.

SYSCALL is privileged. Under a kernel, user programs ask the kernel through INT, and the kernel makes the syscall. Buffer addresses are virtual addresses.

Console

# Name Arguments Result
0 Print character R0 = character
1 Print integer R0 = signed integer
2 Print string R0 = address of a NUL-terminated string
3 Read character R0 = the character. At end of input, R0 = 0 and R5 = 11
4 Read line R0 = buffer, R5 = buffer size R0 = length. The newline is dropped and a NUL is added. At end of input, R5 = 11
5 Print hex R0 = value Printed like 0x1f
6 Print in base R0 = unsigned value, R5 = base (2–36)
7 Print float R0 = single-precision float Printed with up to six significant digits
8 Clear screen
9 Set color R0 = foreground + background × 256, each 0–7 Foreground 0xFF resets the colors. A background of 8 or more selects the terminal's default background

Output to stdout is buffered. vm writes it out before the program reads input, sleeps, writes to stderr or a file, or stops, and otherwise after a million instructions at most.

Files

# Name Arguments Result
10 Open R0 = path, R5 = mode: 0 read, 1 write, 2 append, 3 read/write, 4 truncate and read/write R0 = handle
11 Close R0 = handle
12 Read R0 = handle, R5 = buffer, R6 = count R0 = bytes read; 0 at end of file
13 Write R0 = handle, R5 = buffer, R6 = count R0 = bytes written
14 Seek R0 = handle, R5 = signed offset, R6 = origin: 0 start, 1 current, 2 end R0 = new position
  • Handles 0, 1 and 2 are stdin, stdout and stderr. They work with Read and Write, but cannot be closed or seeked.
  • Up to 16 files can be open at once.
  • Paths are relative to the directory vm runs in.

A program can read and write any file that the user running it can access.

Memory

# Name Arguments Result
20 Allocate R0 = size R0 = the address. On failure, R0 = 0 and R5 = 8
21 Free R0 = address R0 = the status, also in R5
22 Copy R0 = destination, R5 = source, R6 = count R0 = count, or 0 on failure
23 Memory info R0 = memory size, R6 = free heap bytes, R7 = largest free block
24 Fill R0 = address, R5 = byte, R6 = count Sets every byte to R5. R0 = count, or 0 on failure
25 Compare R0 = first address, R5 = second address, R6 = count R0 = the difference of the first bytes that differ, as a signed number, or 0 when the ranges hold the same bytes
26 Find R0 = address, R5 = byte, R6 = count R0 = the index of the first byte equal to R5, or 0xFFFFFFFF
27 Count R0 = address, R5 = byte, R6 = count R0 = how many bytes equal R5
28 Sort R0 = address, R6 = count Sorts count signed 32-bit numbers, smallest first. R0 = count
29 Hash R0 = address, R5 = starting value, R6 = count R0 = the FNV-1a hash of the bytes, going on from R5: 2166136261 starts a new hash, and the hash of one piece goes on with the next

Syscalls 20 to 29 run as native code of the VM, which compiled code calls without stopping. On a failure, such as a range outside memory, they leave 0 in R0.

Process and random numbers

# Name Arguments Result
30 Exit R0 = exit status Stops the program
31 Sleep R0 = milliseconds
32 Time R0 = milliseconds since start
33 Ticks R0 = instructions executed
34 Argument R0 = index, R5 = buffer, R6 = buffer size Copies argument index into the buffer, truncating it to fit. R0 = the argument's full length, or 0xFFFFFFFF if there is no such argument. With a size of 0, only the length is returned
35 Abort R0 = NUL-terminated message, R5 = number of calls to leave out Stops the program the way a fault does, with the message. The report starts where the call that many levels out was made, so a runtime's error routine can blame its caller
36 Clock R0 = the local date and time, as seconds since 1970-01-01 00:00 counted as if the local time were UTC
40 Random R0 = limit R0 = a number below the limit, or any 32-bit value if the limit is 0
41 Seed R0 = seed

Argument 0 is the program path as given to vm.

The random generator is xorshift32 with a fixed default seed. Runs are therefore reproducible unless the program seeds it.

Error codes

These codes appear in R5 after syscalls and in CPUID function 4. Codes 1 to 14 are also the exception vectors of those faults.

Code Meaning
0 No error
1 Invalid instruction
2 Segmentation fault
3 Stack overflow
4 Stack underflow
5 Division by zero
6 Invalid memory address
7 Invalid system call
8 Memory allocation error
9 Alignment error
10 Unhandled interrupt
11 I/O error or end of input
12 Memory protection fault
13 Privilege violation
14 Page fault
15 Single-step trap (a vector only)
16 Instruction limit reached (never delivered to the program)
17 Stopped by a signal (never delivered to the program)
18 Aborted by the program with syscall 35 (never delivered to the program)

Debugger

vm -d program.bin stops before the first instruction. It shows the current source line, with the lines around it when the source file is available.

Command Effect
s, step [N] Execute N instructions (default 1)
n, next Run to the next source line, stepping over calls
f, finish Run until the current subroutine returns
c, continue Run until a breakpoint, a DEBUG instruction, an exception, HALT or a fault
b, break ADDR|SYMBOL Set a breakpoint, for example b main.loop, b 0x10 or b count.c:4
w, watch ADDR|SYMBOL Stop after an instruction changes the 32-bit word at ADDR
d, delete N Delete breakpoint or watchpoint N
lb, breakpoints List breakpoints and watchpoints
ls, symbols List symbols
x, disas [ADDR] [N] Disassemble N instructions (default: 8 at PC)
m, memory ADDR [N] Dump N bytes (default 16)
stack [N] Show N words from the top of the stack (default 8)
bt, backtrace Show the calls and interrupts that led to the current instruction, inlined calls too
r, registers Show registers and flags
cr Show control registers
h, help Show help
q, quit Leave the debugger

Commands that take an address also take a symbol or FILE:LINE, which stands for the lowest address of that line's code. The file name may leave out leading directories.

Any command that runs the program stops when an exception is delivered to a handler, and names the exception. The program's input and output share the terminal with the debugger.

vm -x FILE program.bin takes the commands from FILE instead, which leaves stdin to the program. Each command is echoed after its prompt, lines starting with # are skipped, and the debugger quits at the end of the file:

$ printf 'b read_done\nc\nm line 8\n' > commands.txt
$ ./vm -x commands.txt program.bin < input.txt

Binary format

A VM32 file contains, in order:

  1. A header.
  2. The code bytes, loaded at the code base.
  3. The data bytes, loaded at the data base.
  4. Optional debug information.

All fields are little-endian.

Offset Size Field
0 4 Magic VM32
4 2 Major version (2)
6 2 Minor version (1)
8 4 Header size (40)
12 4 Code base
16 4 Code size
20 4 Data base
24 4 Data size
28 4 Size of the debug information
32 4 Entry point
36 4 Memory in KB that the program asks for, or 0

The VM loads code and data at their bases, which must leave room for the stack at the top of memory. Unless vm -m says otherwise, it gives the program the memory it asks for, or else 1 MB. Headers of version 2.0 end at offset 36 and ask for no memory. The VM rejects other major versions, since their instruction encoding differs.

The debug information holds the symbols, then the source lines, then the code that .inline marked, which files from before inlining leave out. Each string is stored as a 16-bit length followed by its bytes:

u32 symbol count
    string name, u32 value, u8 kind (0 code, 1 data, 2 constant), u32 line, string file
u32 line count
    u32 address, u32 line, string source text, string file
u32 inlined count
    u32 start address, u32 end address, u32 line of the call, string file

A copy comes before the copies inside it.

Object files from vmasm -c start with VMOB and hold both sections, the symbols, the places the linker fills in and the source lines:

"VMOB", u16 version (1), u16 flags (1: sets an entry point), u32 entry offset in the code
u32 code size, u32 code alignment, u32 data size, u32 data alignment, code bytes, data bytes
u32 symbol count
    string name, u8 kind (0 code, 1 data, 2 constant, 3 external), u8 exported, u32 value, u32 line, string file
u32 relocation count
    u8 section (0 code, 1 data), u8 type, u32 offset, u32 target, u32 addend
u32 line count
    u8 section, u32 offset, u32 line, string source text, string file
u32 inlined count
    u8 section, u32 start offset, u32 end offset, u32 line of the call, string file

Code and data symbols hold offsets into their section. A relocation stores the address of its target plus the addend at the offset. For type 1 it stores that minus the address of the word after it, as jumps expect. The target is the file's own code (0) or data (1), or 2 plus the index of a symbol.

Tests

make test runs tests/run.sh, which does six things:

  • Assembles and runs every program in tests/programs. A program's output must match NAME.out, and NAME.in, if present, is fed to its stdin.
  • Checks that every file in tests/errors fails to assemble with the expected message.
  • Compiles and runs every Ore program in ore/tests the same way. Their comment lines start with // instead of ;, and // vmc-args: gives vmc0 more arguments.
  • Checks that every file in ore/tests/errors fails to compile with the expected message.
  • Builds every Ore test with vmc as well, and that build has to meet the same expectations. Since the two compilers write different code, runtime errors are compared by message and Ore lines, and debugger scripts only run against the vmc build. vmc1.bin has to write the same assembly as vmc.bin, all three compilers the same compile errors, the test named vmc checks that vmc compiles itself into vmc.bin, and no_debug that -g0 leaves the debug information out of the binaries of vmc and vmc0.
  • Boots MiniDos on an empty disk for every session in tests/minidos, which types NAME.in and has to show NAME.out, with the display as plain text and the clock held still, and with the programs of ore/minidos/programs and tests/minidos built for it to import. The test is named minidos_NAME.

Comment lines in a test adjust the checks:

Line Meaning
; expect-exit: N Expected exit status (default 0)
; expect-stderr: text Text that must appear on stderr
; expect-error: text Expected assembler or compiler error (only in tests/errors and ore/tests/errors)
; expect-warning: text A warning of vmasm -W that has to appear. Programs are assembled with -W, and every warning needs such a line
; link: modules/a.asm ... Assemble the program and these files from tests/programs with -c, and link them
; expect-link-error: text Expected linker error
; asm-args: ... Extra arguments for the assembler
; ld-args: ... Extra arguments for the linker
; vm-args: ... Extra options for the VM
; program-args: ... Arguments passed to the program
; disk-sectors: N Attach an empty disk image of N sectors

A file NAME.x next to a test holds debugger commands, which the program then runs under with -x, and NAME.keys is passed as a key script with -k. When NAME.err exists, stderr has to match it as a whole.

VM_FLAGS adds options to every run of the VM, so VM_FLAGS='-j 1' make test runs the suite with code compiled from the first jump to it. make test T="display disk" or sh tests/run.sh display disk runs only the tests named. sh tests/run.sh -u NAME rewrites NAME.out, and NAME.err if there is one, from the actual output, provided the program exits with the expected status; a failing test shows the first lines of the difference with control characters made visible.

Programs run inside a temporary directory, so any files they create are discarded. They also run with a limit of 10 million instructions, so a program stuck in a loop fails instead of hanging the suite. The example_* tests include the programs from assembler/examples.

Benchmarks

bench holds the same small programs written in C, Java, Ore and Python: recursive Fibonacci, a prime sieve, quicksort, matrix multiplication, a hash table and a text scanner. python3 bench/run.py builds them, checks that all languages print the same output and shows how long each takes. It skips a language whose tools are missing, and -n, -k and -l set the number of runs, the programs and the languages.

Source layout

Path Contents
src/main.c The vm command line and fault reports
src/monitor.c Tracing and profiling while a program runs
src/vm.c Machine setup, program loading, the fetch-execute step and the loop that runs a program
src/debugger.c The interactive debugger
src/core/ CPU helpers, instruction execution, the fast loop for the common instructions and the compiler that turns them into x86-64 code, memory and heap, syscalls, disassembler, debug info
src/io/ The I/O devices: console, timer, display, keyboard and disk
src/common/ Instruction table, encoding, byte buffers and the binary format, shared by all three tools
assembler/ vmasm: lexer, expressions, symbols, includes and macros, the two passes, output and the register check
linker/ vmld
assembler/examples/ Example programs
ore/compiler/ vmc, the Ore compiler written in Ore: 64-bit constants, syntax trees, lexer, parser, type checker, code generator and the command line
ore/bootstrap/ vmc0, the bootstrap compiler written in C
vmc The script that runs vmc.bin on the VM and assembles what it writes
ore/lib/ The runtime that compiled Ore programs start from, and the standard library in std/
ore/tests/ Ore test programs, and the modules and assembly they use
ore/minidos/ MiniDos, and in programs/ the system programs it puts on disks
bench/ The same programs in C, Java, Ore and Python, and the script that compares them
docs/language.md The Ore language
docs/minidos.md MiniDos
docs/jit.md How compiled code works
include/ Headers
tests/ Test programs, MiniDos sessions and the test runner

About

A virtual machine written in c

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages