Creating Custom Architectures

CREATOR supports defining custom architectures through YAML configuration files. This allows adding new instruction sets or modifying existing ones.

Creating an Architecture File

An architecture file is a YAML file that describes the architecture's properties, including its instruction set, registers, memory layout, and other relevant details. Instructions are defined with their binary encoding, assembly syntax, and semantics.

[!NOTE] We provide a JSON schema for the architecure file at https://creatorsim.github.io/creator/schema/architecture.json.

The actual definition for an instruction is a simple JavaScript code block to manipulate the simulator state. Within this block, you have the registers variable to access the registers (e.g. registers.PC, or registers[value]), as well as CAPI, an API that allows you to interact with the simulator.

[!IMPORTANT] The values stored in the registers are BigInt. Take that into account when reading or writing values:

const foo = registers.PC;  // 420n
registers.PC = foo + 1n;   // 421n

Let's define a simple 8-bit architecture with a few instructions.

The first step is to create a YAML file, e.g., simplearch.yml, and fill out the config. We want an architecture where the word size and byte size are both 8 bits. We'll also make it little-endian, although it doesn't matter in this specific case because a word contains only one byte. pc_offset will be 0. The entry point will be a function named main, or address 0x0 if it doesn't exist; we'll use the ; character to write comments, and the names of the registers won't be case-sensitive (PC == pc). We'll also enable memory alignment and passing convention checks.

[!IMPORTANT] The value of the program counter register (program_counter) inside the instruction definitions is affected by the pc_offset.

pc_offset is the offset that we'll add to the value of program counter the instruction "sees".

E.g. if pc_offset is -4, and we're executing an instruction at 0x0, the "real" PC is 0x4 (because of the fetch performed at the start of the cycle), but the value of registers.PC (the "virtual" PC) will be 0x0.

version: 2.0.0
config:
  name: Simple8Bit
  description: A simple custom 8-bit architecture
  word_size: 8
  byte_size: 8
  endianness: little_endian
  pc_offset: 0
  main_function: main
  start_address: 0x0
  comment_prefix: ;
  sensitive_register_name: false
  memory_alignment: true
  passing_convention: true

For the registers, we'll make a control register bank with a PC program counter register (we'll mark that with the program_counter property), and another integer register bank with a A and B register, as well as a SP stack pointer register (stack_pointer property).

[!NOTE] A floating point bank would be defined as:

 # ...
 - name: Floating point registers
   type: float
   double_precision: true  # or `false`, if single-precision

All of these registers will be 8 bits, be initialized (value) and have a default value (default_value) of 0, and will be both readable (read property) and writable (write property).

[!NOTE] The encoding property will be used when decoding binary instructions, in this case we'll just make it sequential.

name is a list because a register can have multiple values, e.g. in RISC-V register zero can be also called x0, and so on. These names must all be unique.

register_files:
  - name: Control registers
    type: ctrl
    double_precision: false
    registers:
      - name:
          - PC
        nbits: 8
        encoding: 0
        value: 0
        default_value: 0
        properties:
          - read
          - write
          - program_counter
  - name: Integer registers
    type: int
    double_precision: false
    registers:
      - name:
          - A
        encoding: 0
        nbits: 8
        value: 0
        default_value: 0
        properties:
          - read
          - write
      - name:
          - B
        encoding: 1
        nbits: 8
        value: 0
        default_value: 0
        properties:
          - read
          - write
      - name:
          - SP
        encoding: 2
        nbits: 8
        value: 0
        default_value: 0
        properties:
          - read
          - write
          - stack_pointer

For the memory layout, we'll use a simple .text, .data, .stack layout:

memory_layout:
  text:
    start: 0x0000
    end: 0x03FF
  data:
    start: 0x0400
    end: 0x7FFF
  stack:
    start: 0x8000
    end: 0xFFFF

Now it's time for the instructions. We want an architecture with two instructions: NOP and ADD in the base extension. The NOP instruction does nothing, while the ADD instruction adds the values of two registers and stores the result in a destination register.

As the instructions will have the same form, we can define an instruction template called standard defining that our instructions will be 1 word long (nwords), take 1 clock cycle (clk_cycles) and use the full word as an operation code (type co) field. We then override in each instruction the value of that field.

templates:
  - name: standard
    nwords: 1
    clk_cycles: 1
    fields:
      - name: opcode
        type: co
        startbit: 7
        stopbit: 0
        order: 0

instructions:
  base:
    - name: nop
      template: standard
      fields:
        - field: opcode
          value: "0x00"
      definition: ""

    - name: add
      template: standard
      fields:
        - field: opcode
          value: "0x80"
      definition: |
        const oldValueA = registers.A;
        registers.A = (oldValueA + registers.B) & 0xFFn;
        registers.F = CAPI.ARCH.calculateFlags_ADD(oldValueA, registers.B);

You'll also need to add some directives in order to mark things like the data and text segments, or defining data types on the data segment. Here you have a complete example:

directives:
  - name: .data
    action: data_segment
    size: null
  - name: .text
    action: code_segment
    size: null
  - name: .bss
    action: global_symbol
    size: null
  - name: .zero
    action: space
    size: 1
  - name: .space
    action: space
    size: 1
  - name: .align
    action: align
    size: null
  - name: .balign
    action: balign
    size: null
  - name: .globl
    action: global_symbol
    size: null
  - name: .string
    action: ascii_null_end
    size: null
  - name: .asciz
    action: ascii_null_end
    size: null
  - name: .ascii
    action: ascii_not_null_end
    size: null
  - name: .byte
    action: byte
    size: 1
  - name: .half
    action: half_word
    size: 2
  - name: .word
    action: word
    size: 4
  - name: .dword
    action: double_word
    size: 8
  - name: .float
    action: float
    size: 4
  - name: .double
    action: double
    size: 8

Pseudoinstructions

Pseudoinstructions are special instructions recognized by the assembler that get automatically expanded to a sequence of instructions recognized by the simulator during execution. Their definition in the architecture is very similar to real instructions:

pseudoinstructions:
  base:
    - name: mv
      fields:
        - field: reg1
          type: INT-Reg
          suffix: ","
        - field: reg2
          type: INT-Reg
      definition: |
        addi reg1, reg2, 0;
      help: Copy the value in register rs2 into register rd.

The main difference with real instructions is their definition field. Instead of being used during execution of the program to manipulate the simulator state, it is used to define how the instruction should be transformed into a sequence of instructions. The definition can take one of two forms: a instruction sequence template, or a JavaScript function body. We'll describe each form below.

[!NOTE] While not recommended, other pseudoinstructions can be used in the definition of a pseudoinstruction. The pseudoinstructions are expanded recursively until all instructions are real.

Instruction Sequence Form

This is the simplest method to use, although it's still enough for most cases. This method allows transforming the pseudoinstruction into a single, fixed instruction sequence. In this case, the definition field should be a single string containing the resulting assembly instructions, separated by newlines or semicolons. The example above uses this method.

In order to forward the arguments of the pseudoinstruction to the resulting instructions, the name of the corresponding fields can be used as placeholders for the value. This works even in sub-expressions, allowing arithmetic manipulation of the arguments during the transformation. Modifiers can be very useful for doing bit manipulation (e.g extracting a range of bits out of an immediate value) during the expansion of a pseudoinstructions. For PC-relative address calculations, the . symbol always evaluates to the address in which the surrounding instruction will be loaded into. Using this, for example, the la RISC-V pseudoinstruction can be defined as follows:

definition: |
    auipc rd, %hi(addr - .);
    addi rd, rd, %lo(addr - (. - 4));

[!NOTE] This form allows the use of forward references by the user. For this reason, this form should be preferred when possible.

JavaScript Function Form

This method allows implementing complex transformations of the pseudoinstruction. It's mostly used when a pseudoinstruction can conditionally expand into multiple different instruction sequences, or when complex processing of its arguments is needed. In this case, the definition field should be the body of a JavaScript function prefixed by js:\n. The signature of this function should be:

(pc: bigint, args: Array<number | bigint | string | null>) => string

Where:

  • pc: address in which the pseudoinstruction is being assembled into.
  • args: array of evaluated pseudoinstruction arguments in the order they appear in the assembly syntax, where:
    • number is used for expressions that evaluate to a float.
    • bigint is used for expressions that evaluate to an integer.
    • string is used for single identifiers (typically register names).
    • null is used for other expressions that can't be evaluated (expressions containing undefined labels/forward references, or other errors like division by 0).
  • The returned string must be the definition in instruction sequence form, as described in the previous section.
  • The function is allowed to throw any JS value that can be converted to a string, which will be displayed in error messages

[!NOTE] In this form, it's up to the definition whether to throw an error on null arguments or simply forward the expression using its field name in the returned string. When possible, the later should be preferred as it allows forward references and provides better errors for the user when the expression contains errors.

Example usage of this method:

pseudoinstructions:
  base:
    - name: li
      fields:
        - field: rd
          type: INT-Reg
          suffix: ","
        - field: val
          type: imm-signed
      definition: |
        js:
        const val = args[1];
        // If the value is small (1 byte signed), use a single instruction
        if (val !== null && val >= -128 && val <= 127) return "addi rd, x0, val";
        // Otherwise, use multiple instructions
        return `
          lui rd, val >> 8;
          ori rd, rd, val & 0xFF;
        `
      help: Load the immediate, imm, into register rd.

Modifiers

Modifiers are a set of predefined operators which allow to easily perform bit manipulation on expressions. They allow easily taking a slice of bits from their input. In the assembly code, modifiers can be used in expressions as %<name>(<expression>) (e.g. lui rd, %hi(0xDEADBEEF)). An example definition is:

modifiers:
  hi:
    lower_signed: true
    output_signed: false
    range: [12, 32]
  lo:
    lower_signed: false
    output_signed: true
    range: [0, 12]

Where:

  • range defines the slice of bits to take, using a right-exclusive range.
  • lower_signed defines whether the start - 1 bit should be added to the result. This is mostly intended for cases where the modifiers are used to split a constant into multiple different parts for loading, when the lower bits will be added as a signed integer to the loaded upper bits.
  • output_signed defines whether resulting bit string should be interpreted as a signed or unsigned integer.

Plugins

Plugins are extra architecture-specific CAPI modules that are loaded with the architecture (CAPI.ARCH). These add extra custom functionalities that can be more easily achieved by being inside the engine.

config:
  # ...
  plugin: riscv

[!NOTE] For more information about the API and available functionalities, see CAPI Reference.

Interrupt Support

Now, let's take our architecture and add support for some simple maskable and nonmaskable interrupts.

Custom handler

We'll define two new 1-bit integer registers MIP (Maskable Interrupt Pending) and NIP (Nonmaskable Interrupt Pending) that will be set to 1 when an interrupt of the type is pending. We'll also define another 1-bit integer register IE to enable (value of 1) and disable (value of 0) maskable interrupts.

We just need to add them to simplearch.yml:

register_files:
  # ...
  - name: Integer registers
    # ...
    registers:
      # ...
      - name:
          - MIP
        encoding: 2
        nbits: 1
        value: 0
        default_value: 0
        properties:
          - read
          - write
      - name:
          - NIP
        encoding: 3
        nbits: 1
        value: 0
        default_value: 0
        properties:
          - read
          - write
      - name:
          - IE
        encoding: 4
        nbits: 1
        value: 1
        default_value: 1
        properties:
          - read
          - write

Now we have to define some functions to determine how interrupts work in this architecture.

First, we need to define how to determine if an interrupt happened. CREATOR has some predefined types of interrupts, and here we'll use InterruptType.Maskable and InterruptType.Nonmaskable. We have to write a function that returns the type of interrupt (InterruptType), or null if there is no interrupt:

[!NOTE] You don't have to check if interrupts are enabled here, we'll define that later.

interrupts:
  check: |
    if (registers.NIP) return InterruptType.Nonmaskable;
    if (registers.MIP) return InterruptType.Maskable;
    return null;

Then, we must define how different types of interrupts can be created and cleared. We'll receive the desired type (InterruptType) inside the type variable:

interrupts:
  # ...
  create: |
    switch (type) {
      case InterruptType.Maskable:
        registers.MIP = 1n;
        break;
      case InterruptType.Nonmaskable:
        registers.NIP = 1n;
        break;
    }

  clear: |
    switch (type) {
      case InterruptType.Maskable:
        registers.MIP = 0n;
        break;
      case InterruptType.Nonmaskable:
        registers.NIP = 0n;
        break;
    }

  global_clear: |
    registers.MIP = 0n;
    registers.NIP = 0n;

[!NOTE] clear is optional, it gets overriten by global_clear if it's not defined

Next, how they can be enabled and disabled, per type (and globally), as well as how to check if they are enabled. For the sake of simplicity, we'll assume nonmaskable interrupts can't be disabled.

# ...
interrupts:
  # ...
  is_enabled: |
    switch (type) {
      case InterruptType.Maskable:
        return registers.MIE === 1n;
      case InterruptType.Nonmaskable:
        // nonmaskable are always enabled
        return true;
    }
    return false;

  is_global_enabled: |
    return true;

  enable: |
    switch (type) {
      case InterruptType.Maskable:
        return registers.MIE = 1n;
        break;
      // we don't need to do anything for nonmaskable
    }

  disable: |
    switch (type) {
      case InterruptType.Maskable:
        registers.IE = 0n;
        break;
      // can't disable nonmaskable
    }

  global_enable: |
    registers.IE = 1n;

  global_disable: |
    registers.IE = 0n;

[!NOTE] enable and disable are optional, they gets overriten by global_ counterparts if it's not defined. is_global_enabled is optional, and defaults to return true.

Finally, we define the custom interrupt handler. This handler will disable interrupts, store PC in the stack, and jump to 0x0. To disable interrupts, we do it "manually" by clearing IE, but we can also use CAPI to reuse the code we already defined.

[!NOTE] Using these CAPI functions is recommended way of doing it, as it allows the application to (secretly) keep track of these interrupts.

interrupts:
  handlers:
    custom: |
      // disable interrupt
      CAPI.INTERRUPTS.disable(type);

      // store PC in stack
      registers.SP = (registers.SP - 1n) & 0xFFn;
      CAPI.MEM.write(registers.SP, 1, registers.PC);

      // jump to handler
      registers.PC = 0n;

Many architectures have a specific instruction to return from an interrupt, so let's make one, reti. This instruction will clear and enable interrupts and jump back to the address stored in the stack:

instructions:
  base:
    # ...
    - name: reti
      template: standard
      fields:
        - field: opcode
          value: "0x01"
      definition: |
        // enable interrupts
        CAPI.INTERRUPTS.globalEnable();

        // pop return address from stack
        registers.PC = CAPI.MEM.read(registers.SP, 1);
        registers.SP = (registers.SP + 1n) & 0xFFn;

        // notify UI that handler has finished
        CAPI.INTERRUPTS.clearHighlight();

CREATOR handler

As we mentioned in Interrupt Handling, CREATOR has two different interrupt handlers: the default "CREATOR" one, and a custom architecture-defined one. We also mentioned that the default handler is able to handle "architecture-defined system calls". Let's see a more concrete example, by implemening them in our architecture.

[!TIP] Why would we want this? Because we want to have our cake and eat it too.

Before interrupts were added to CREATOR, the definition of RISC-V's ecall function didn't create an interrupt, it just executed the desired system call depending on the value of register a7. But we wanted to have "real" interrupts and a "real" ecall. The problem is that this required an interrupt handler, and we didn't want to force our users to use it, we didn't want to silently include a kernel, and we didn't want to have two architectures: one with interrupts and one without.

So the solution (compromise) we found was this one, a second (default) interrupt handler that can be programmed in JS.

These system calls will generate a new type of interrupt (InterruptType.EnvironmentCall), so let's quickly modify the architecture to take them into account. We'll also add a new EIP register to signal that that type of interrupt is pending.

register_files:
  # ...
  - name: Control registers
    # ...
    registers:
      # ...
      - name:
          - EIP
        encoding: 1
        nbits: 1
        value: 0
        default_value: 0
        properties:
          - read
          - write

# ...
interrupts:
  check: |
    if (registers.NIP) return InterruptType.Nonmaskable;
    if (registers.MIP) return InterruptType.Maskable;
    if (registers.EIP) return InterruptType.EnvironmentCall;
    return null;

  is_enabled: |
    switch (type) {
      case InterruptType.Maskable:
        return registers.MIE === 1n;
      case InterruptType.EnvironmentCall:
        return registers.EIE === 1n;
      case InterruptType.Nonmaskable:
        // nonmaskable are always enabled
        return true;
    }
    return false;

  enable: |
    switch (type) {
      case InterruptType.Maskable:
        // we don't need to do anything for nonmaskable
        break;
      default:
        registers.IE = 1n;
        break;
    }

  disable: |
    switch (type) {
      case InterruptType.Maskable:
        // can't disable nonmaskable
        break;
      default:
        registers.IE = 0n;
        break;
    }

  create: |
    switch (type) {
      case InterruptType.Maskable:
        registers.MIP = 1n;
        break;
      case InterruptType.Nonmaskable:
        registers.NIP = 1n;
        break;
      case InterruptType.EnvironmentCall:
        registers.EIP = 1n;
        break;
    }

  clear: |
    switch (type) {
      case InterruptType.Maskable:
        registers.MIP = 0n;
        break;
      case InterruptType.Nonmaskable:
        registers.NIP = 0n;
        break;
    }

  global_clear: |
    registers.MIP = 0n;
    registers.NIP = 0n;
    registers.EIP = 0n;

  # ...

Now we can define our syscall instruction:

# ...
instructions:
  base:
    # ...
    - name: syscall
      template: standard
      fields:
        - field: opcode
          value: "0x02"
      definition: CAPI.INTERRUPTS.create(InterruptType.EnvironmentCall);

The convention will be that the type of system call we want to use will be stored in register A, while register B will hold extra information. For example, a system call to print a number will print whatever B is holding.

To allow CREATOR's handler to handle them, as system calls depend on each architecture, we must define that in the architecture definition:

# ...
interrupts:
  handlers:
    # ...
    creator_syscall: |
      switch (registers.A) {
        case 1n:
          CAPI.SYSCALL.print(registers.B, 'int32');
          break;
      }

      // notify UI that handler has finished
      CAPI.INTERRUPTS.clearHighlight();

Privileged instructions

CREATOR also supports having privileged instructions that can only be executed in kernel mode, by adding the privileged property. This is the reason for having system calls in the first place, we allow the user to ask doing things that require a higher privilege (e.g. accessing I/O) without giving them that privilege itself.

Let's say that in our architecture, an interrupt always triggers an execution mode change. To achieve that, we should modify the custom handler so that it sets kernel mode (CAPI.INTERRUPTS.setKernelMode();) and modify the reti instruction so that it goes back to user mode (CAPI.INTERRUPTS.setUserMode();). As reti should only be used while dealing with interrupts, we'll make it a privileged instruction.

instructions:
  base:
    # ...
    - name: reti
      # ...
      properties:
        - privileged
      definition: |
        // enable interrupts
        CAPI.INTERRUPTS.globalEnable();

        // pop return address from stack
        registers.PC = CAPI.MEM.read(registers.SP, 1);
        registers.SP = (registers.SP + 1n) & 0xFFn;

        // notify UI that handler has finished
        CAPI.INTERRUPTS.clearHighlight();

        // set user mode
        CAPI.INTERRUPTS.setUserMode();

# ...

interrupts:
  handlers:
    custom: |
      // disable interrupt
      CAPI.INTERRUPTS.disable(type);

      // set kernel mode
      CAPI.INTERRUPTS.setKernelMode();

      // store PC in stack
      registers.SP = (registers.SP - 1n) & 0xFFn;
      CAPI.MEM.write(registers.SP, 1, registers.PC);

      // jump to handler
      registers.PC = 0n;

Timers

Another way of generating interrupts is through the use of a timer. CREATOR, nevertheless, allows a custom timer handler, which gives many more possibilities.

The functioning of timers in CREATOR if that, each tick (a tick is defined as a number of clock cycles in tick_cycles), if the timer is enabled (is_enabled), it calls the handler (handler) and advances the timer value (advance). The handler is supposed to compare the timer value and perform whatever action.

As an example, we'll create a new TIME control register to keep time, and a user-configurable TIMECMP register to compare that timer to, allowing the user to create their own timers. We'll also add a register to enable timers:

register_files:
  - name: Control registers
    # ...
    registers:
      # ...
      - name:
          - TIME
        nbits: 8
        encoding: 2
        value: 0
        default_value: 0
        properties:
          - read

  # ...

  - name: Integer registers
    # ...
    registers:
      # ...
      - name:
          - TIMECMP
        encoding: 5
        nbits: 8
        value: 0
        default_value: 0
        properties:
          - read
          - write

      - name:
          - TE
        encoding: 6
        nbits: 1
        value: 1
        default_value: 1
        properties:
          - read
          - write

Our timer will advance once per cycle, compare both TIME and TIMECMP and fire up a Timer interrupt. It will advance continuously, and overflow going back to 0. For the enabling/disabling we use the new TE register.

timer:
  tick_cycles: 1  # one tick per cycle
  advance: |
    registers.TIME = (registers.TIME + 1n) % (2n**8n - 1n);  // to prevent overflow
  handler: |
    if (registers.TIME === registers.TIMECMP) {
      CAPI.INTERRUPTS.create(InterruptType.Timer);
      registers.TIME = 0;  // you might want to reset here
    }
  is_enabled: |
    return !!registers.TE;  // has to return bool
  enable: |
    registers.TE = 1;
  disable: |
    registers.TE = 0;

[!NOTE] We're generating a new type of interrupt (InterruptType.Timer), but we're not handling it in the interrupt handler.

This is left as an exercise for the reader.

Devices

Devices are memory-mapped I/O that the user can interact with (through memory). CREATOR exposes a set of devices with their own functionalities, and they are configurable in the architecture.

Each device has a string ID (id), a class - or type of device (cls), and the addresses of their control (ctrl_addr) and status registers (status_addr) and data segment (data). You can also enable or disable them (enable - by default, they are enabled)

Let's set up a console device at the end of the data segment. It will overlap, but the device has priority:

devices:
  - id: console
    cls: ConsoleDevice
    ctrl_addr: 0x7FF0
    status_addr: 0x7FF4
    data:
      start: 0x7FF8
      end: 0x7FFF
    # enabled: true

[!NOTE] Now the user can talk to the device by reading and writing to the end of the data segment. But the current architecture does not expose any instructions to do that.

This is left as an exercise for the reader.

You can find more information about the implemented devices in Devices.

results matching ""

    No results matching ""