So You Want to Build a Language VM - Part 15 - Assembler CLI Improvements

Makes the CLI interface a bit more robust and useful

CLI Improvements

Having to start the interpreter, then type .load_file, etc is cumbersome. Let’s change it so that the VM tries to execute the file given to it as an argument. There’s a super handy-dandy crate called clap in Rust that will make this trivial. The behavior we want is:

  • If the user types iridium and nothing else, they will go directly to a REPL

  • If the user types iridium /path/to/valid/*.iasm, that is, they want to execute a file of code directly, it should do that then exit

    Note
    If you want to be able to run the iridium executable from anywhere, you’ll want to drop it in /usr/local/bin or some other location on your PATH.

Add the Dependency

To your Cargo.toml file, add:

clap = { version = "2.32", features = ["yaml"] }

To learn all about clap, I’m going to recommend reading through their website. It’s well-written and should cover everything we need. If you want to skip, know that clap is a tool that makes it easy to write an app that works like a typical CLI command: flags, help, etc.

Using clap

First, add the following to the top of main.rs:

extern crate clap;
use clap::{Arg, App, SubCommand};

Next, we’ll factor out the code to start the REPL. Create this function in main.rs:

/// Starts a REPL that will run until the user kills it
fn start_repl() {
    let mut repl = repl::REPL::new();
    repl.run();
}

We’ll need a function to read data from a file. Put this in main.rs:

/// Attempts to read a file and return the contents. Exits if unable to read the file for any reason.
fn read_file(tmp: &str) -> String {
    let filename = Path::new(tmp);
    match File::open(Path::new(&filename)) {
      Ok(mut fh) => {
        let mut contents = String::new();
        match fh.read_to_string(&mut contents) {
          Ok(_) => {
            return contents;
          },
          Err(e) => {
            println!("There was an error reading file: {:?}", e);
            std::process::exit(1);
          }
        }
      },
      Err(e) => {
        println!("File not found: {:?}", e);
        std::process::exit(1)
      }
    }
}

Those are pretty boilerplate. Here’s the new main() function and imports:

use std::path::Path;
use std::fs::File;
use std::io::prelude::*;

#[macro_use]
extern crate nom;

#[macro_use]
extern crate clap;

use clap::App;

pub mod assembler;
pub mod instruction;
pub mod repl;
pub mod vm;

fn main() {
    let yaml = load_yaml!("cli.yml");
    let matches = App::from_yaml(yaml).get_matches();
    let target_file = matches.value_of("INPUT_FILE");
    match target_file {
        Some(filename) => {
            let program = read_file(filename);
            let mut asm = assembler::Assembler::new();
            let mut vm = vm::VM::new();
            let program = asm.assemble(&program);
            match program {
                Some(p) => {
                    vm.add_bytes(p);
                    vm.run();
                    std::process::exit(0);
                },
                None => {}
            }
        },
        None => {
            start_repl();
        }
    }
}

If the user types iridium /something.iasm, then this will try to:

  1. Read data from that file

  2. Sends it through our nom parser

  3. If successful, adds it to the VM, runs it, then exits with a 0

If they type iridium, a REPL starts.

cli.yml

I opted to have the config for this in src/cli.yml, but you can place it elsewhere or use a different config option. My example one is:

name: iridium
version: "0.0.1"
author: Fletcher Haynes <fletcher@subnetzero.io>
about: Interpreter for the Iridium language
args:
    - INPUT_FILE:
        help: Path to the .iasm or .ir file to run
        required: false
        index: 1
But clap allows for two other methods: code and macro. Feel free to customize if you want.

Makefile

I’ve left my Makefile in the repo, so you can use it to help install the binaries in /usr/local/bin/ if you want.

EPIE Header

Since we have some words left in this part, let’s teach our assembler how to write out the PIE header. Let’s add some needed constants to the top of src/assembler/mod.rs:

const PIE_HEADER_PREFIX: [u8; 4] = [45, 50, 49, 45];
const PIE_HEADER_LENGTH: usize = 64;

In the impl for Assembler, add this function:

fn write_pie_header(&self) -> Vec<u8> {
    let mut header = vec![];
    for byte in PIE_HEADER_PREFIX.into_iter() {
        header.push(byte.clone());
    }
    while header.len() <= PIE_HEADER_LENGTH {
        header.push(0 as u8);
    }
    header
}

This will write out our header, which right now is 4 bytes and 60 0s. Its important to pad the header so that we can use those bytes later if needed. The last function in the Assembler impl we need to tweak is assemble, so that it actually uses the header generator:

// Don't forget to add this at the top of the file too
use assembler::PIE_HEADER_PREFIX;

pub fn assemble(&mut self, raw: &str) -> Option<Vec<u8>> {
    match program(CompleteStr(raw)) {
        Ok((_remainder, program)) => {
            // First get the header so we can smush it into the bytecode letter
            let mut assembled_program = self.write_pie_header();
            self.process_first_phase(&program);
            let mut body = self.process_second_phase(&program);

            // Merge the header with the populated body vector
            assembled_program.append(&mut body);
            Some(assembled_program)
        },
        Err(e) => {
            println!("There was an error assembling the code: {:?}", e);
            None
        }
    }
}

Now if you run cargo test, you’ll notice that test_bytes_assemble fails. This is because we have added 64 bytes. Change the 28 to a 92 in that test for now.

The VM

Of course, our VM doesn’t know yet to look for the header. We can add a verify_header function to it. In src/vm.rs, add this to the impl of the VM:

/// Processes the header of bytecode the VM wants to execute
fn verify_header(&self) -> bool {
    if self.program[0..4] != PIE_HEADER_PREFIX {
        return false;
    }
    true
}

Last, we’ll set the VM’s Program Counter to 65, since we don’t yet have anything to do with the header.

Broken Tests

These changes will break any of the tests in vm.rs that use vm.run() as opposed to vm.run_once(). Why? Because vm.run() now contains header validation, but run_once() doesn’t check the header.

I wrote a simple function in the test module to prepend the header:

fn prepend_header(mut b: Vec<u8>) -> Vec<u8> {
    let mut prepension = vec![];
    for byte in PIE_HEADER_PREFIX.into_iter() {
        prepension.push(byte.clone());
    }
    while prepension.len() <= PIE_HEADER_LENGTH {
        prepension.push(0);
    }
    prepension.append(&mut b);
    prepension
}

Use it like this in a test:

#[test]
fn test_mul_opcode() {
    let mut test_vm = get_test_vm();
    test_vm.program = vec![3, 0, 1, 2];
    test_vm.program = prepend_header(test_vm.program);
    test_vm.run();
    assert_eq!(test_vm.registers[2], 50);
}

End

I think we’re ready for the two-pass workflow, so we’ll start on that next section!


If you need some assistance with any of the topics in the tutorials, or just devops and application development in general, we offer consulting services. Check it out over here or click Services along the top.