Simple CPU v1a: Assembler - updated 27/11/2019

Once you have built your processor its tempting to simply hand-code the machine code i.e. write out the raw 1's and 0's for each instruction. This may sound painful, but you do get your eye in, you start to see the matrix :). Combined with a bit of cut and paste, you can get by taking this approach. However, after a while, especially if your coming back to programs you wrote a little while ago, you start to have toooo much fun and you need an a basic assembler to make your life a little easier. Therefore, rule number one of programming: you can only write so much machine code before you write an assembler :).

Table of Contents

Input file format
Output file formats
Python assembler - version 1.0
M4 pre-processor
Python assembler - version 1.1

If you Google the word "assembler" you get: "a program for converting instructions written in low-level symbolic code into machine code" which is pretty much on the money. At their hearts assemblers are very simple pieces of software converting the assembly language mnemonics we can read into the binary 1's and 0s that are the machine code that the processor speaks. Note, remember there is a one-to-one mapping between assembler and machine code i.e. one line of assembler = one machine code instruction, unlike a high level language like C, were one line of C can equal hundreds of lines of assembler (or more). The aim of this assembler is a basic, no frills piece of software, programmed in Python, to simplify code development, converting text into numbers as shown in figure 1.

Figure 1 : the assembly process, Assembly code (top), 1's and 0s (bottom)

Note, real world assemblers also need a little bit of optimisation and extra house keeping functionality to allow them to be practical, which we will discuss at the end, but not the main aim of this cheap and cheerful assembler.

From a historical perspective assemblers are defined a by the number of times they read the assembler source file i.e. a 1-pass or a 2-pass design. Note, i guess now days we would say one pass or multiple pass assemblers. I think this original distinction was due to how data was stored in these old machines i.e. tape. Rewinding these tapes to read a file again takes time, so back then multiple pass assemblers were not time efficient. An example of the joys of tape is available here: (Video). I confess do like these old machines, real computers: mechanically large, with lots of lights and sounds :). Back to assemblers, the main differences between these two approaches (1 or 2 pass) was how do you handle forward references e.g. JUMP exit. The label "exit" is the address of an instruction further ahead in the program, the first time the assembler reads through a program the address of this label is unknown. For 1-pass assemblers, this address must be resolved later, for 2-pass this is calculated on a previous pass. For more info on these differences refer to : (Link). Whatever approach is taken you still need to produce the same result i.e. the program's machine code. This assembler is going to be very simple, so it doesn't quite fit into your traditional definitions. You could say its a one pass, but that's not quite correct as ive chopped out a lot of the functionality e.g. labels are not supported etc.

Input file format

To avoid the whole forward reference problem i decided to pass this problem over to the programmer i.e. make it a manual two pass process, where the programmer identifies the addresses of the instructions, manually updates the source code with the correct addresses and then re-runs the assembler. Combined with the M4 macro pre-processor (discussed later) this produced a workable solutions for the types of program used on these types of simpleCPU v1a based systems (Link).

Note, the aim of this assembler is to demonstrate the mechanics of converting assembly code into machine code, it is not intended to be a fully functional assembler. The identified deficiencies of this assembler are "fixed" in its second implementation: simpleCPUv3_as.py, discussed in a different project.

The source code format is stored as an ASCII file, an example is shown below:

#
# TEST PROGRAM
#

move 0x00

add 1
jumpNZ 1
jump 0

Comments are indicated using the '#' character, empty lines are passed unaltered. Lines starting with a character are considered instructions, constants can be used represented in hexadecimal (leading 0x) or decimal. Data values are limited to the range: 0-255. The simpleCPU uses a fixed length instruction format i.e. each instruction is represented using 16bits, stored in one memory location. In the above example the addresses used by the JUMP instructions are easy to manually enter as the boot/reset vector i.e. the address of the first instruction on power-up, is known to be address 0 and each instruction takes one address/location. However, for larger programs manually counting lines will become a bit more tricky, therefore, the assembler dumps out an intermediate file in which it calculates each instruction's memory address, as shown below:

#
# TEST PROGRAM
#

000 move    0x00

001 add     1
002 jumpNZ  1
003 jump    0

The user can scan through this file, identify the addresses of the branch targets and update the original source text files accordingly i.e. replace the original place holder / dummy values, just as a 2-pass assembler would do. Note, this new address field is a decimal value, varying from 0 - 255. This file is then converted into the raw machine code used to program the computer:

0000 0000 1001 A001 8000 

This first value is the starting address, followed by four hexadecimal (16bit) values, each representing one of the original instructions e.g. move 0x00 is mapped to the bit pattern 0000, add 1 to 1001 etc.

Output file formats

Before we can write the assembler we also need to know what the required output file formats are. For the simpleCPU v1a, this comes in two flavours: 8bit to program the two EPROMs used in the bread-board version, and 16bit to program the FPGA version. To program the EPROMs i used is BK Precvision 844USB, as shown in figure 2.

Figure 2 : programmer

This programmer supports two simple ASCII HEX formats (descriptions taken from help menu):

ASCII HEX format

Each data byte is represented as 2 hexadecimal characters, and is separated with a white space from following data bytes. The address for data bytes is set by using a sequence of $Annnn, characters, where nnnn is the 4-hex characters of the address. The comma is required. Although each data byte has an address, most are implied. Data bytes are addressed sequentially unless an explicit address is included in the data stream. Implicitly, the file starts an address 0 if no address is set before the first data byte. The file begins with a STX (Control-B) character (0x02) and ends with a ET (Control-C) character (0x03). Note: The checksum field consists of 4 hex characters between the $S and comma characters. The checksum immediately follows an end code.

Here is an example of ASCII HEX file. It contains the data "Hello, World" to be loaded at address 0x1000:

^B $A1000, 
48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 0A ^C 
$S0452, 

ASCII SPACE format

A very simple hex file format similar as ASCII HEX without checksum field, without start (STX) and end (ETX) characters. Each data byte is represented as 2 hexadecimal characters, and is separated with a white space from other data bytes. The address field is also separated by white space from data bytes. The address is set by using a sequence of 4-8 hex characters.

Here is an example of ASCII SPACE file. It contains the data "Hello, World" to be loaded at address 0x1000:

0001000 48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 0A

I decided to keep it simple and went for the ASCII SPACE format. This output will be used to program the two 2764 8bit EPROMs (Link). Therefore, the assembler will generate two output files: representing the low byte and high byte of the 16bit instruction. The FPGA version of this processor is implemented on an Xilinx FPGA so need to use Xilinx specific output files:

COE

This file format is used to initialise CORE-gen memory ip blocks (Link). An example is shown below, basically a small header block defining the number based used, comma separated values, followed by the memory contents. Note, assumes you start at address 0 i.e. no address field. This file is only read during the synthesis stage, therefore, if this file is updated the complete design will need to be re-synthesised even if the processor's hardware has not been modified, otherwise the FPGA configuration bit file will not be updated. This can be a bit of a pain when developing / testing software as repeatedly re-synthesising and the associated place-and-route phase can take a significant amount of time.

memory_initialization_radix = 16;
memory_initialization_vector = 
0001, 4100, 4510, 8003, 0000, 0000, 0000, 0000, 
0000, 0000, 0000, 0000, 0000, 0000, 0000, 0000,

MEM

To get around the problem of re-synthesising the processor each time the software is updated, Xilinx provides the data2mem tool. This allows you to update the contents of a memory component (BlockRAM), within a bit file i.e. the FPGA configuration file, without having to go through the re-synthesise process. The data2mem tools use the .mem file format, as described in the data2mem user guide (Link). An example is shown below, a simple format, defining the start address using the '@' character, then a list of numbers defined as hexadecimal values. Note, this file format uses a reversed nibble representation i.e. least significant data nibble first (data is reversed when compared to the previous format).

@00000000
1000 0014 0154 3008 0000 0000 0000 0000
0000 0000 0000 0000 0000 0000 0000 0000

DAT

The final format that ive used in the past is a raw ASCII binary format. Note, not sure if this evolved from stuff i was doing or if its an actual format, anyway i use this format to configure VHDL RAM models for simulations. Each line defines one memory location's binary value, specifying the address as a decimal value and the data as a binary string :

0014 0000000000000010
0015 0000000000000001
0016 0001000011111111

In this example the first line defines address 14, and the data value 2 i.e. the instruction move 0x02, address and data are separated by a single space. Addresses can be in any order i.e. do not need to be sequential as in this example.

Python Assembler - version 1.0

To simplify hardware construction this version of the processor only has a very limited instruction set:

In this instruction syntax X=Not-used, K=Constant and A=Address. The complexity of an instruction is also defined by its addressing mode i.e. not just how much number crunching it does, but how it fetches its operands (data). Again, to simplify the required hardware these instructions are limited to simple addressing modes:

Note, reverted back to the more traditional use of LOAD and STORE, rather than INPUT and OUTPUT for this version of the processor, to define the instructions that read and write to memory i.e. LOAD=INPUT=READ, STORE=OUTPUT=WRITE.

As shown in the above table, bits (11 downto 8) i.e. the lower nibble of the high byte, are not used by the CPU (marked XXXX). These four bits could be removed to reduce the 16bit instruction down to a 12bit instruction. However, as memory ICs used in the bread-board implementation are 8bit, we don't reduce IC count going to 12bit. Therefore, i stuck with 16bits as it allows for possible future instruction set expansion. For FPGA implementations it would make a difference as the design can be reconfigured to match the new instruction format, however, to keep things simple going to standardise on a fixed 16bit instruction format.

Version 1.0 of the assembler is shown below, you can also download the assembler here: (Link). Input text files are given the extension .asm, output ASCII files the extension .asc. This initial implementation generates the EPROM programming files, this output data is split across two files. The user passes the output file "name" this is extended to "high_name" and "low_name" for the high and low EPROMs. At this time the FPGA files are not generated, but the program does generate a combined 16bit version "name.asc", i will update the FPGA side for version 2. The basic usage is:

Usage: simpleCPUv1a_as.py -i <input_file.asm> -o <output_file> -a <address_offset> -t <input_file_type>

./simpleCPUv1a_as.py -i test -o test 

File extensions are automatically added by the program. The default start address is 0x00, but this can be altered using the -a option. This is handy when you are generating code for a shared ROM i.e. the bread-boarded implementation can store up to eight programs in its EPROM, each is aligned on a 256 byte page. You can also assemble a pre-processed file i.e. a file where the address field has been added (as discussed in the above input file format sections). This is sometimes useful, you can rename the auto generated intermediate tmp.asm file and continue to add instructions with their addresses. Also wrote a program to auto renumber these addresses if you lost count, or cut and pasted in blocks of code with the wrong address fields. This extra program can be downloaded here: (Link).

#!/usr/bin/python
import getopt
import sys
import re

#
# MAIN PROGRAM
#

def simpleCPUv1a_as(argv):

  if len(sys.argv) <= 1:
    print ("Usage: simpleCPUv1a_as.py -i <input_file.asm> -o <output_file>") 
    print ("                          -a <address_offset>")
    print ("                          -t <input_file_type>")
    return

  # init variables #
  version = '1.0'
  source_filename = 'default.asm'
  tmp_filename = 'tmp.asm'
  high_byte_filename = 'default_high.asc'
  low_byte_filename = 'default_low.asc'
  word_filename = 'default.asc'

  address = 0
  byte_count = 0

  s_config = 'a:i:o:t:'
  l_config = ['address', 'input', 'output', 'type']

  input_file_present = False
  input_file_preprocessed = False

  instruction_address = 0
  instruction_count   = 0

  # capture commandline options #
  try:
    options, remainder = getopt.getopt(sys.argv[1:], s_config, l_config)
  except getopt.GetoptError as m:
    print "Error: ", m
    return

  # extract options #
  for opt, arg in options:
    if opt in ('-o', '--output'):
      if ".asc" in arg:
        high_byte_filename = "high_" + arg
        low_byte_filename = "low_" + arg
        word_filename = arg
      else:
        high_byte_filename = "high_" + arg + ".asc"
        low_byte_filename = "low_" + arg + ".asc"
        word_filename = arg + ".asc"
    elif opt in ('-i', '--input'):
      input_file_present = True
      if ".asm" in arg:
        source_filename = arg
      else:
        source_filename = arg + ".asm"
    elif opt in ('-a', '--address'):
      address = int(arg)
    elif opt in ('-t', '--type'):
      if arg == "2":
        input_file_preprocessed = True  
   
  # exit if no input file present # 
  if input_file_present:

    # open files #
    try:
      source_file = open(source_filename, "r")
    except IOError: 
      print "Error: Input file does not exist."
      return 

    try:
      high_byte_file = open(high_byte_filename, "w")
      low_byte_file = open(low_byte_filename, "w")
      word_file = open(word_filename, "w")
      tmp_file = open(tmp_filename, "w")
    except IOError: 
      print "Error: Could not open output files"
      return 

    # generate tmp file with addresses for checking etc#
    instruction_address = address
    while True:
      line = source_file.readline()
      if line == '':
        break 

      # pass if pre-processed #
      if input_file_preprocessed:
        tmp_file.write( line )
      else:

        # otherwise count instructions and insert addresses #
        if line[0] == '\r' or line[0] == '\n' or line[0] =='#' or line[0] ==' ':
          tmp_file.write( line )
        else:
          if line[0].isalpha():
            text = re.sub(' +', ' ', line)
            words = text.split(' ')
    
            outputString = str.format('{:03}', instruction_address) + " "
            instruction_address += 1

            outputString = outputString + str.format('{:<6}', words[0]) + " "

            for i in range(1, len(words)):
              outputString = outputString + " " + words[i]

            tmp_file.write( outputString )
 
    # limit test #
    if address >  256:
      print "Error: program bigger than 256 instruction limit"
      return 

    tmp_file.close()

    # open TMP file #
    try:
      tmp_file = open(tmp_filename, "r")
    except IOError: 
      print "Error: could not output temp file"
      return 

    instruction_count = 0
    instruction_address = address

    # write start address to file #
    high_byte_file.write(str.format('{:04X}', instruction_address) + ' ')
    low_byte_file.write(str.format('{:04X}', instruction_address) + ' ')
    word_file.write(str.format('{:04X}', instruction_address) + ' ')

    while True:
      line = tmp_file.readline()
      if line == '':
        break 

      if line[0] =='\r' or line[0] =='\n' or line[0] =='#' or line[0] ==' ':
        pass
      else:
        text = re.sub(' +', ' ', line.lower())
        words = text.split(' ')

        opcode = ''
        operand = ''
        
        if words[0].isdigit():

          # match opcode #
          if words[1]   == "move":
            opcode = "00 "
          elif words[1] == "add":
            opcode = "10 " 
          elif words[1] == "sub":
            opcode = "20 "
          elif words[1] == "and":
            opcode = "30 "
          elif words[1] == "load":
            opcode = "40 "
          elif words[1] == "store":
            opcode = "50 "
          elif words[1] == "jump":
            opcode = "80 "
          elif words[1] == "jumpu":
            opcode = "80 "
          elif words[1] == "jumpz":
            opcode = "90 "
          elif words[1] == "jumpnz":
            opcode = "A0 "
          else:
            print "Error: invalid opcode" 
            print words 
            return

          if len(words) >= 2:
            data = words[2].rstrip()
            if '0x' not in data:
              if int(data) < 256:
                operand = str.format('{:02X}', int(data)) + ' ' 
              else:
                print "Error: invalid operand"
                print words 
                return
            else:
              if len(data) == 4:
                operand = data[2:4] + ' ' 
              elif len(tmp) == 3:
                operand = "0" + data[2] + ' ' 
              else:
                print "Error: invalid operand"
                print words 
                return
          else:
            print "Error: invalid operand"
            print words 
            return

        # if opcode and operand good write instruction #
        print opcode, operand
        if opcode == '' or operand == '':
          print "Error: invalid instruction"
          print words
          return
      
        else:
          instruction_count += 1

          # update EPROM files #
          high_byte_file.write(opcode)
          low_byte_file.write(operand)
          word_file.write(opcode.strip() + operand)


          byte_count += 1
          if byte_count == 16:
            byte_count = 0
            high_byte_file.write("\n")
            low_byte_file.write("\n")
            word_file.write("\n")

            instruction_address += 16
            addressString = str.format('{:04X}', instruction_address) + ' '
            high_byte_file.write(addressString)
            low_byte_file.write(addressString)
            word_file.write(addressString)

    # close files #
    source_file.close() 
    high_byte_file.close()
    low_byte_file.close()
    word_file.close()
    tmp_file.close()

    # display info #
    outputString = "Number of instructions : " + str(instruction_count)
    print( outputString )

    if instruction_count > 0:
      max_address = instruction_count + address - 1
    outputString = "Address range : " + str(address) + " to " + str(max_address)
    print( outputString )

  else:
    print "Error: Input file not specified"
    return 

if __name__ == '__main__':
  simpleCPUv1a_as(sys.argv)

To test this assembler the bread-boarded test program described here: (Link) was used as the source file: (testCode.asm). The output files generated are shown below:

#HIGH BYTE
0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 
0010 10 10 10 10 20 20 20 20 20 10 20 10 20 10 20 10 
0020 20 10 20 10 20 10 20 10 30 30 30 30 30 30 30 30 
0030 00 50 00 50 00 50 00 50 00 50 00 50 00 50 00 50 
0040 40 40 40 40 40 40 40 40 00 50 00 50 00 50 00 50 
0050 00 50 00 50 00 50 00 50 00 90 00 00 A0 00 00 A0 
0060 80 00 00 90 80 00 00 80 
#LOW BYTE
0000 00 01 02 04 08 10 20 40 80 40 20 10 08 04 02 01 
0010 ff f0 0f 01 01 f0 0f 01 01 01 02 02 04 04 08 08 
0020 10 10 20 20 40 40 80 80 7f 3f 1f 0f 07 03 01 00 
0030 01 10 02 11 04 12 08 13 10 14 20 15 40 16 80 17 
0040 10 11 12 13 14 15 16 17 01 ff 02 ff 04 ff 08 ff 
0050 10 ff 20 ff 40 ff 80 ff 00 5B 0f 01 5E 0f 00 61 
0060 62 0f 01 65 66 0f 00 00
#COMBINED
0000 0000 0001 0002 0004 0008 0010 0020 0040 0080 0040 0020 0010 0008 0004 0002 0001 
0010 10ff 10f0 100f 1001 2001 20f0 200f 2001 2001 1001 2002 1002 2004 1004 2008 1008 
0020 2010 1010 2020 1020 2040 1040 2080 1080 307f 303f 301f 300f 3007 3003 3001 3000 
0030 0001 5010 0002 5011 0004 5012 0008 5013 0010 5014 0020 5015 0040 5016 0080 5017 
0040 4010 4011 4012 4013 4014 4015 4016 4017 0001 50ff 0002 50ff 0004 50ff 0008 50ff 
0050 0010 50ff 0020 50ff 0040 50ff 0080 50ff 0000 905B 000f 0001 A05E 000f 0000 A061 
0060 8062 000f 0001 9065 8066 000f 0000 8000 

The high and the low byte files were uploaded into the EPROM programmer and used to program the two EPROMs, all worked fine. You can see the simpleCPU running this code in this short video (Video). This processor is discussed in detail here: (Link).

M4 pre-processor

This assembler is intentionally simple, but you can add a little more functionality by using the existing M4 pre-processor that is available on most Linux systems (Link). The M4 macro processor is a powerful beast and requires you to screw on your "different way of thinking head" e.g. the example on the previous Wiki page. At first you would think that such functionality is not possible, then the power and confusion of recursive programming kicks in :). For the programs used in this version of the simpleCPU i use it to simply reduce the amount of code that needs to be cut and pasted. Consider the "Hello World" example used on the bread-boarded implementation discussed here: (Link). If you examine this code you can see that most of it is very similar, simply writing data to the LCD. If the processor supported subroutines we would not use this "cut and paste" approach. Note, they are supported in version 3 of the simpleCPU. However, for now we can not use subroutines, but we can define a macro that will dramatically reduce the amount of code we need to write. Looking at the code we can see that the section of code that needs to be repeat is:

move   0x08 - transfer 0010
store  0xFF - write to output port
add    0x80 - set E high
store  0xFF - write to output port
sub    0x80 - set E low
store  0xFF - write to output port

Here we load the data into the ACC, then pulse the MSB to transfer the data to the LCD. These six lines can be defined as a macro, as shown below:

define( lcd_write_nibble,`move $1 
store  0xFF
add    0x80
store  0xFF
sub    0x80
store  0xFF')

This code defines the macro "lcd_write_nibble", each time this string is found in the source code the above six instructions are used to replace its. Note, parameters are positional in calling macro, labelled $1, $2, $3 etc. This macro is stored in the file simpleCPUv1a.m4 and can be passed to the M4 pre-processor along with the assembly language text file. The quotes for the m4 pre-processor are a matched pair of single quotes "`" and "'", they are different. To illustrate this in practice consider the first two data transfers in the Hello World code previously discussed, shown below: Hello_World_Demo.asm

# Initialise display
# ------------------

move   0x00 - load ACC with 0
store  0xFF - write to output port

# 0011 0011 Initialise
# --------------------

#        E RS D7 D6 | D5 D4 X X
# 0011 - 0 0  0  0  | 1  1  0 0  = 0x0C
# 0011 - 0 0  0  0  | 1  1  0 0  = 0x0C

move   0x0C - transfer 0011
store  0xFF - write to output port
add    0x80 - set E high
store  0xFF - write to output port
sub    0x80 - set E low
store  0xFF - write to output port

move   0x0C - transfer 0011
store  0xFF - write to output port
add    0x80 - set E high
store  0xFF - write to output port
sub    0x80 - set E low
store  0xFF - write to output port

# 0011 0010 Initialise
# --------------------

#        E RS D7 D6 | D5 D4 X X
# 0011 - 0 0  0  0  | 1  1  0 0  = 0x0C
# 0010 - 0 0  0  0  | 1  0  0 0  = 0x08

move   0x0C - transfer 0011
store  0xFF - write to output port
add    0x80 - set E high
store  0xFF - write to output port
sub    0x80 - set E low
store  0xFF - write to output port

move   0x08 - transfer 0010
store  0xFF - write to output port
add    0x80 - set E high
store  0xFF - write to output port
sub    0x80 - set E low
store  0xFF - write to output port

This could be rewritten as:

# Initialise display
# ------------------

move   0x00 - load ACC with 0
store  0xFF - write to output port

# 0011 0011 Initialise
# --------------------

#        E RS D7 D6 | D5 D4 X X
# 0011 - 0 0  0  0  | 1  1  0 0  = 0x0C
# 0011 - 0 0  0  0  | 1  1  0 0  = 0x0C

lcd_write_nibble( 0x0C ) - transfer 0011
lcd_write_nibble( 0x0C ) - transfer 0011

# 0011 0010 Initialise
# --------------------

#        E RS D7 D6 | D5 D4 X X
# 0011 - 0 0  0  0  | 1  1  0 0  = 0x0C
# 0010 - 0 0  0  0  | 1  0  0 0  = 0x08

lcd_write_nibble( 0x0C ) - transfer 0011
lcd_write_nibble( 0x08 ) - transfer 0010

Now if we run the command line code:

m4 simpleCPUv1a.m4 Hello_World_Demo.asm

The following output is generated:

# Initialise display
# ------------------

move   0x00 - load ACC with 0
store  0xFF - write to output port

# 0011 0011 Initialise
# --------------------

#        E RS D7 D6 | D5 D4 X X
# 0011 - 0 0  0  0  | 1  1  0 0  = 0x0C
# 0011 - 0 0  0  0  | 1  1  0 0  = 0x0C

move   0x0C  
store  0xFF
add    0x80
store  0xFF
sub    0x80
store  0xFF - transfer 0011
move   0x0C  
store  0xFF
add    0x80
store  0xFF
sub    0x80
store  0xFF - transfer 0011

# 0011 0010 Initialise
# --------------------

#        E RS D7 D6 | D5 D4 X X
# 0011 - 0 0  0  0  | 1  1  0 0  = 0x0C
# 0010 - 0 0  0  0  | 1  0  0 0  = 0x08

move   0x0C  
store  0xFF
add    0x80
store  0xFF
sub    0x80
store  0xFF - transfer 0011
move   0x08  
store  0xFF
add    0x80
store  0xFF
sub    0x80
store  0xFF - transfer 0010

This text is by default dumped to standard out, but can equally be redirected to a file:

m4 simpleCPUv1a.m4 Hello_World_Demo.asm > Hello_World_Demo_Updated.asm

As this example shows you can significantly reduce coding and improve readability without having to update the assembler. Macros can also be combined e.g. the macro "lcd_write_byte", this combines the lcd_write_nibble() macro with the built in eval() macro, as shown below. Note, the hardest thing to workout with the M4 pre-processor is what to quote or not to quote.

define( lcd_write_word, `lcd_write_nibble(  eval( (`$1' & 240) >> 4) )
lcd_write_nibble( eval( `$1' & 15) )' ) 

This new macro can be used in the assembly language program to further reduce the line code.

# 0011 0010 Initialise
# --------------------

#        E RS D7 D6 | D5 D4 X X
# 0011 - 0 0  0  0  | 1  1  0 0  = 0x0C
# 0010 - 0 0  0  0  | 1  0  0 0  = 0x08

lcd_write_word( 0xC8 ) 

The syntax for the eval() macro is shown in figure 3. For more info on the M4 pre-processor refer to: (Link).

Figure 3 : eval syntax

Python Assembler - version 1.1

Been working on different versions of the simpleCPU for a while now. Ive tried different assemblers, one based on instruction set simulators and other pyhton based implementations, but came back to this one due to its simplicity i.e. easy to adjust / modify to suit the needs of the different simpleCPU versions. However, the lack of symbolic labels is a bit of a game breaker. For small programs the previous version is fine, but as soon as you get more than a handful of addresses you start to see an exponential rise in coding errors. Therefore, i decided to make version 1.1, a two pass assembler. This is easy to do in python, a simple dictionary search and replace. Also as i'm mostly working on the FPGA version trimmed down the output file formats a little, to match requirements. The new and improved version 1.1 assembler is shown below, you can also download the assembler here: (Link).

#!/usr/bin/python
import getopt
import sys
import re

#
# MAIN PROGRAM
#

def simpleCPUv1a_as(argv):

  if len(sys.argv) <= 1:
    print ("Usage: simpleCPUv1a_as.py -i ")
    print ("                          -o ") 
    print ("                          -a ")
    print ("                          -t ")
    return

  # init variables #
  version = '1.1'
  source_filename = 'default.asm'
  tmp_filename = 'tmp.asm'
  word_filename = 'default.asc'
  mem_filename = 'default.mem'
  data_filename = 'default.dat' 

  address = 0
  byte_count = 0

  s_config = 'a:i:o:'
  l_config = ['address', 'input', 'output']

  input_file_present = False

  instruction_address = 0
  instruction_count   = 0

  label_dictionary = {}

  # capture commandline options #
  try:
    options, remainder = getopt.getopt(sys.argv[1:], s_config, l_config)
  except getopt.GetoptError as m:
    print "Error: ", m
    return

  # extract options #
  for opt, arg in options:
    if opt in ('-o', '--output'):
      if ".asc" in arg:
        word_filename = arg
      elif ".dat" in arg:
        data_filename = arg
      elif ".mem" in arg:
        mem_filename = arg
      else:
        word_filename = arg + ".asc"
        data_filename = arg + ".dat"
        mem_filename = arg + ".mem"
    elif opt in ('-i', '--input'):
      input_file_present = True
      if ".asm" in arg:
        source_filename = arg
      else:
        source_filename = arg + ".asm"
    elif opt in ('-a', '--address'):
      address = int(arg)
   
  # exit if no input file present # 
  if input_file_present:

    # open files #
    try:
      source_file = open(source_filename, "r")
    except IOError: 
      print "Error: Input file does not exist."
      return 

    try:
      word_file = open(word_filename, "w")
      mem_file = open(mem_filename, "w")
      data_file = open(data_filename, "w")
      tmp_file = open(tmp_filename, "w")
    except IOError: 
      print "Error: Could not open output files"
      return 

    # scan through code looking for labels

    instruction_address = address
    
    while True:
      line = source_file.readline()
      if line == '': 
        break
      if line[0] == '\r' or line[0] == '\n' or line[0] =='#':
        continue
      else:
        if ":" in line:
          key = re.sub(':\n', '', (line.replace(" ", "")).replace("\t", ""))
          label_dictionary[key] = instruction_address
        else:
          text = re.sub('\s+', ' ', line)
          words = text.split(' ')
          if words[1] != "":
            instruction_address += 1  

    #print label_dictionary
    source_file.close()     
    source_file = open(source_filename, "r")

    # generate tmp file with addresses for checking etc#

    instruction_address = address
    while True:
      line = source_file.readline()
      if line == '': 
        break
      if line[0] == '\r' or line[0] == '\n' or line[0] =='#':
        tmp_file.write( line )
      else:
        text = re.sub('\s+', ' ', line)
        words = text.split(' ')
          
        #print words  
        if ":" in text:
          continue
        elif words[1] == '':
          continue
        else:

          outputString = str.format('{:03}', instruction_address) + " "
          instruction_address += 1 

          for i in range(0, len(words)):
            if words[i] in label_dictionary:
              key = words[i]
              outputString = outputString + " " + str(label_dictionary[key])
            else:
              if words[i] != '':
                outputString = outputString + " " + words[i]

          outputString = outputString + "\n"
          tmp_file.write( outputString )

    # limit test #
    if address >  256:
      print "Error: program bigger than 256 instruction limit"
      return 

    tmp_file.close()

    # open TMP file #
    try:
      tmp_file = open(tmp_filename, "r")
    except IOError: 
      print "Error: could not output temp file"
      return 

    instruction_count = 0
    instruction_address = address

    # write start address to file #
    word_file.write(str.format('{:04X}', instruction_address) + ' ')

    while True:
      line = tmp_file.readline()
      if line == '':
        break 

      if line[0] =='\r' or line[0] =='\n' or line[0] =='#' or line[0] ==' ':
        pass
      else:
        text = re.sub('\s+', ' ', line.lower())
        words = text.split(' ')

        opcode = ''
        operand = ''
        
        if words[0].isdigit():

          # match opcode #
          if words[1]   == "move":
            opcode = "00 "
          elif words[1] == "add":
            opcode = "10 " 
          elif words[1] == "sub":
            opcode = "20 "
          elif words[1] == "and":
            opcode = "30 "
          elif words[1] == "load":
            opcode = "40 "
          elif words[1] == "store":
            if words[3] == '':
              opcode = "50 "
            else:
              if int(words[2]) > 15:
                print "Error: invalid opcode" 
                print words 
                return
              else:
                opcode = "5" + str(words[2]) + " " 
          elif words[1] == "addm":
            opcode = "60 "
          elif words[1] == "subm":
            opcode = "70 "
          elif words[1] == "jump":
            opcode = "80 "
          elif words[1] == "jumpu":
            opcode = "80 "
          elif words[1] == "jumpz":
            opcode = "90 "
          elif words[1] == "jumpnz":
            opcode = "A0 "
          else:
            print "Error: invalid opcode" 
            print words 
            return

          if (len(words) == 5):   
            data = words[3].rstrip()     
          elif (len(words) >= 2) and (len(words) < 5):
            data = words[2].rstrip()
          else:
            print "Error: invalid operand"
            print words 
            return

          if '0x' not in data:
            try:
              if int(data) < 256:
                operand = str.format('{:02X}', int(data)) + ' ' 
              else:
                print "Error: invalid operand (>255)"
                print words 
                return
            except ValueError:
                print "Error: invalid operand"
                print words 
                return
          else:
            if len(data) == 4:
              operand = data[2:4] + ' ' 
            elif len(data) == 3:
              operand = "0" + data[2] + ' ' 
            else:
              print "Error: invalid operand"
              print words 
              return


        # if opcode and operand good write instruction #
        #print opcode, operand
        if opcode == '' or operand == '':
          print "Error: invalid instruction"
          print words
          return
      
        else:
          data_file.write(str.format('{:04}', instruction_count) + ' ')
          bin_value = str.format('{:016b}', int((opcode.strip() + operand), 16)) 
          data_file.write( bin_value )
          data_file.write("\n")

          # update EPROM files #
          word_file.write(opcode.strip() + operand)

          # update mem file
          mem_file.write('@' + str.format('{:04X}', (instruction_count * 2)) + ' ')
          data_string = opcode.strip() + operand
          mem_file.write(data_string[3] + data_string[2] + data_string[1] + data_string[0] + "\n")

          instruction_count += 1
          byte_count += 1

          if byte_count == 16:
            byte_count = 0
            word_file.write("\n")

            instruction_address += 16
            addressString = str.format('{:04X}', instruction_address) + ' '
            word_file.write(addressString)

    # close files #
    source_file.close() 
    word_file.close()
    mem_file.close()
    data_file.close()
    tmp_file.close()

    # display info #
    outputString = "Number of instructions : " + str(instruction_count)
    print( outputString )

    if instruction_count > 0:
      max_address = instruction_count + address - 1
    outputString = "Address range : " + str(address) + " to " + str(max_address)
    print( outputString )

  else:
    print "Error: Input file not specified"
    return 

if __name__ == '__main__':
  simpleCPUv1a_as(sys.argv)

This new version has added support for the new instruction formats i.e. SUBM and two operand STORE instructions. Finally, to fit into the standard toolchain format i started to think about adding a linker. To start the ball rolling, gone for a simple loader i.e. something to take the raw machine code and convert it into a format that can be used to initialise the vhdl memory model. Code below:

#!/usr/bin/python
import getopt
import sys
import re
import os

#
# MAIN PROGRAM
#

def simpleCPUv1a_ld(argv):

  if len(sys.argv) <= 1:
    print ("Usage: simpleCPUv1a_ld.py -i ")
    print ("                          -o ") 
    return

  # init variables #
  version = '1.1'
  source_filename = 'default.mem'
  output_filename = 'memory.vhd'

  s_config = 'i:o:'
  l_config = ['input', 'output']

  input_file_present = False

  # capture commandline options #
  try:
    options, remainder = getopt.getopt(sys.argv[1:], s_config, l_config)
  except getopt.GetoptError as m:
    print "Error: ", m
    return

  # extract options #
  for opt, arg in options:
    if opt in ('-o', '--output'):
      if ".vhd" in arg:
        output_filename = arg
      else:
        output_filename = 'memory.vhd'
    elif opt in ('-i', '--input'):
      input_file_present = True
      if ".mem" in arg:
        source_filename = arg
      else:
        source_filename = arg + ".mem"
   
  # exit if no input file present # 
  if input_file_present:
    commandString = "./data2mem -bm mem.bmm -bd " + source_filename + " -o h " + output_filename
    #print commandString
    os.system( commandString )
    print "Success, memory image file " + output_filename + " generated for " + source_filename
  else:
    print "Error: Input file not specified"
    return 

if __name__ == '__main__':
  simpleCPUv1a_ld(sys.argv)

At the moment this code just passes the into and output file names to the Xilinx data2mem program. For the moment ive organised the simpleCPU's memory around four blockRams (internal FPGA memory cores). Each blockRam stores a 4bit nibble, meaning that in total this memory can store 4K x 16bit. This is very wasteful given that the simpleCPU only uses 256 x 16bits. However, other versions of the processor i'm currently working on use 12bit and 16bit address busses, so using a standard 4K memory component means that i can reuse this hardware / software on these other machines. Also blockRam is a loose-it or use-it resource so that they would be free anyway.The basic usage is:

Usage: simpleCPUv1a_ld.py -i <input_file.asm> -o <output_file>

./simpleCPUv1a_ld.py -i test

This will produce the memory.vhd file that is then used to initialise the vhdl memory model defined in the schmatic, described here: (Link).

Creative Commons Licence

This work is licensed under a Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.

Contact email: mike@simplecpudesign.com

Back