Skip to content

dj-on-github/py6502

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

109 Commits
 
 
 
 
 
 
 
 
 
 

Repository files navigation

A python based 6502 assembler, disassembler and simulator
David Johnston

The Philosophy
--------------

I enjoy old-school electronics and programming. I've learned over time that there is a benefit to combining old-school methods with modern tools.

For example:
1) My day job is a chip designer, focusing on cryptgraphic circuits. For a particular project I either needed a big state machine or a small CPU. An old school 8 bit processor took me 4 hours to implement in synthesizable RTL and on a modern silicon process it runs blindingly fast in a tiny bit of silicon area. It came with lots of tools already since the instruction set was standard.

2) I wrote a Point-of-Sale system for a family store, since the off-the-shelf solutions were expensive and none of them matched the needs of the store. Instead of going for some windows interface or touch driven app on a tablet, I wrote the user interface using the old-school curses library for a text based interface that was designed to minimize keystrokes at the checkout. The staff loved it since the key strokes took a couple of minutes to learn and I wrote it in Python, bringing the simplicity and power of modern programming tools.

In both cases, the benefits of the efficiencies of old school methods with the power of modern methods yielded something better than either.

This project is a proof-of-concept for this idea. I use it for hacking my Apple //e.  It's a 6502 assembler, disassembler and simulator written in Python. Many old 6502 assemblers exist, but they suffer from inconsistent formats, directives and macro processors. In particular the macro processors tended to be horrible.

The thing that makes it a little different is that instead of offering a 'better assembler language' or 'better macro language' I've stripped down the programs to the very basic functions but written them such that they are intended to be called from a python program that feeds it assembler and gets object code back. This then makes python the macro language. So you get the ability to write assembly code normally, or you can write python to automate the code generation or generate parameterized code, or unroll loops or any number of other things, but using a nice language that makes it easy rather than a set of confusing macro directive written in 1978.

If you want to instrument and test of a bit of code, it's easy to assemble it and then write a program in python to iterate over your chosen input states and check the outputs and simulate the code repeatedly, calling the simulation of each instruction directly from python, coding in whatever analysis meets your needs.

The simulator and disassembler works directly with the object code and symbol table from the assembler.

An Simple Example: Sending Assembly to the Assembler From Python
----------------------------------------------------------------
This python (see src/small_example.py) assembles a few instructions

from asm6502 import asm6502

        thecode = """
                ORG  $100
        start:
                LDA #$10
                LDX #$00
        loop:
                STA $1000,x
                INX
                SBC #$01
                BPL loop
                RTS
        """
        
        lines = thecode.splitlines()
        
        a = asm6502()
        (lst,sym)= a.assemble(lines)
        # inspect output..
        for line in lst:
            print(line)
        print()
        for symbol in sym:
            print(symbol)
        print()
        a.print_object_code()

The output looks like this:
        LISTING
        1    0000 :                                
        2    0100 :                  org $100      
        3    0100 : start:                         
        4    0100 :         A9 10    lda #$10      
        5    0102 :         A2 00    ldx #$00      
        6    0104 : loop:                          
        7    0104 :         9D 00 10 sta $1000,x   
        8    0107 :         E8       inx           
        9    0108 :         E9 01    sbc #$01      
        10   010A :         10 F8    bpl loop      
        11   010C :         60       rts           
        12   010D :   
        
        SYMBOL TABLE
        start      = $0100
        loop       = $0104
        
        OBJECT CODE
        *
        0100: A9 10 A2 00 9D 00 10 E8 E9 01 10 F8 60         
        *

The Listing
-----------

If after running that, you typed this:
    >>> a.listing

you would see the assembly output as an array:

['1    0000 :                                ', '2    0100 :                  org $100      ', '3    0100 : start:                         ', '4    0100 :         A9 10    lda #$10      ', '5    0102 :         A2 00    ldx #$00      ', '6    0104 : loop:                          ', '7    0104 :         9D 00 10 sta $1000,x   ', '8    0107 :         E8       inx           ', '9    0108 :         E9 01    sbc #$01      ', '10   010A :         10 F8    bpl loop      ', '11   010C :         60       rts           ']

The Symbol Table
----------------

You can also see the symbol table as a dictionary after assembling:
    >>> a.symbols
    {'start': 256, 'loop': 260}

The Object Code Map
-------------------

Finally, you can see the list of object code values, but in decimal, since that's how python displays by default:
        >>> a.object_code[256:269]
        [169, 16, 162, 0, 157, 0, 16, 232, 233, 1, 16, 248, 96]
        >>>

The assembler keeps a complete map of the 64K memory space of the 6502 and populates the code and values into that map. The 'object_code' class variable is a list containing the map. Each untouched location is set to -1. Other values indicate the 8 bit value at that location.

After assembling the code into the map, it is possible to add in other things to the map by assigning to the object_code list. E.G.

        a.object_code[0xfffd] = 0x00
        a.object_code[0xfffc] = 0x10
Which would set the reset vector to 0x1000.

Pseudo-ops and Directives
-------------------------

; Comment
ORG address - Set the current assembly location
EQU - Define a label and its value (see "Labels" below)
DB - Generate 8-bit values
DW - Generate 16-bit values
DDW - Generate 32-bit values
DQW - Generate 64-bit values
LE - For multi word data (DW, DDW and DQW) set the encoding to little endian
BE - For multi word data (DW, DDW and DQW) set the encoding to big endian
The assembler defaults to little endian.

DB, DW, DDW and DQW all work in the same way:

* The argument is one or more comma-separated *items*
* Each *item* is one of
  * Decimal number
  * Hex number (indicated by $ prefix)
  * Octal number (indicated by @ prefix)
  * Binary number (indicated by % prefix)
  * Label (the & prefix is accepted but optional)
  * String (delimited "thus")
  * An expression combining numbers and labels with + and -, optionally
    prefixed with < (low byte) or > (high byte) - see "Expressions" below
* Each string *item* is transformed into one 8-bit *value* per character (the ASCII code of the character)
* Every other item represents a single *value*
* Every *value* is zero-padded or truncated to generate one or more *bytes* in the object code (1 byte-per-value for DB, 2 for DW etc.)
* The assembler generates a warning when a *value* is truncated

Examples:

DB $0d, "the rain in spain", $0
DB entry, loop                   ; both truncated to low byte
DW entry, "fish"                 ; each character padded to 16-bits
DW jumptable+4                   ; label arithmetic
DB <entry, >entry                ; low byte, high byte

Prefixes
--------

The prefixes $, @, % can also be used with numeric arguments to instructions

Labels
------

A word followed by a colon makes a label. It can be on its own line, or in front of an instruction or directive. Examples:

              ORG $100
alabel:                 ; A label on its own - value is $100 because of previous ORG
              NOP       ; 1 byte op-code
anotherlabel: STA #$10  ; A label with an instruction - value is $101
yalabel:      EQU $1234 ; A label with a value - value is $1234
loop:         STA #$20  ; A label with an instruction - value is $103 because "STA #$10" occupies 2 bytes

An EQU directive explicitly declares the value of a label. A label without an EQU takes the implicit address where it occurs. All EQU directives must be resolved in pass1. A consequence of this is that the value of an EQU can be a backward symbolic reference
but cannot be a forward symbolic reference.

Any address or 16 bit data field can be replaced with a declared label and the label address will be inserted there.
In DB/DW/DDW/DQW declarations a label can be used directly; the & prefix is accepted for backward compatibility but is optional. Examples:

        dw  $1000, @2000, 123  ; Implicit numbers have a base prefix.
ttable: dw  l1, l2, l3         ; labels in a DW
        dw  &l1, &l2, &l3      ; the same, with the optional & prefix
        org $1000
l1:     lda #$20
        jmp skip
l2:     lda #$30
        jmp skip
l3:     lda #$40
skip:   sta $10

Expressions
-----------

Wherever a number or label is accepted (instruction operands, DB/DW/DDW/DQW items, ORG and EQU values) you can use a simple expression instead:

* Terms are numbers in any base ($ hex, @ octal, % binary, decimal) or labels
* Terms can be added and subtracted with + and -
* The whole expression can be prefixed with < to take the low byte or > to take the high byte of its value

Examples:

        sta dp+1        ; the byte after dp
        lda #<h_last    ; low byte of the address of h_last
        lda #>h_last    ; high byte of the address of h_last
        lda buffer+2,x  ; indexing works with expressions too
        dw  vector-1    ; expressions in data declarations
        db  <entry+3    ; low byte of (entry + 3)

Note there is no operator precedence or bracketing; expressions are evaluated left to right with + and - only.

Assembling Into the Same Map
----------------------------

You can call the assembler() class method multiple times; by default, the object_code map is retained between calls. This allows you to assemble multiple pieces of code into different locations and they will be added to the map.
The print_object_code() class method displays the current object code map.
E.G. The following code assembles a sequence, then modifies its origin, then reassembles it:
        from asm6502 import asm6502
        a = asm6502()
        lines = [' ORG $1000', ' NOP', ' LDA #$20', 'here: NOP', ' DB 10,11,12,13', ' RTS']
        a.assemble(lines)
        lines[0] = ' ORG $2000'
        a.assemble(lines)
        a.print_object_code()

This yields this memory map with the same code in two places.
        >>> a.print_object_code()
        OBJECT CODE
        *
        1000: EA A9 20 EA 0A 0B 0C 0D 60
        *
        2000: EA A9 20 EA 0A 0B 0C 0D 60
        *

The default behaviour of the assemble() class method is set by variables that have default values:

        a.assemble(lines, clear_lst=True, clear_sym=True, clear_obj=False)

Setting clear_sym=False retains the symbol table from the previous call, and setting clear_lst=False allows multiple pieces of source code to be grouped together.

Setting clear_sym=True also allows you to establish symbols prior to running the assembler, then reference them in the code:

        from asm6502 import asm6502
        a = asm6502()
        lines = [' ORG $1000', ' NOP', ' LDA #$20', ' JMP syblue', 'here: NOP', ' RTS']
        a.symbols['skyblue'] = 0x4500
        a.assemble(lines, clear_sym=FALSE)

Getting hex dump format data in and out
---------------------------------------
After assembling you can output the object code as a hex dump with * marking the unpopulated spaces

        >>> a.print_object_code()
        >>> a.print_object_code(canonical=True)

The second version used unix hexdump format, which includes an ASCII decode of printable characters.

An optional offset can be used to affect the address field in the output. For example, to generate data for a PROM programmer that expects the addresses to be relative to the first location in the ROM, choose an offset that causes the start address to wrap back to 0 like this:

        >>> a.print_object_code(canonical=True, offset=-0xb400)

You can also load the object code map from a file in hexdump format, also with an optional offset:

        >>> a.load_object_code("some_file.txt", offset=0x400)


Getting IntelHex format data out
--------------------------------
After assembling you can output the object code in intelhex format.
calling the intelhex() method returns lines of intelhex as a list.

        >>> a.intelhex()
        [':10010000A000B90000990020B90001990021B900', ':1001100002990022B90003990023B90004990024', ':10012000B90005990025B90006B90026B9000799', ':0E0130000027B90008990028C8D0D04C59FF', ':10100000A000B90000990020C8D0D0A900850085', ':1010100002A9018501A9218503B2009202E602E6', ':1010200000D0F8E603E601A501C909D0EE4C59FF', ':00000001FF']
        
Calling the print_intelhex() method outputs intelhex format object code to stdout.        
        >>> a.print_intelhex()
        :10010000A000B90000990020B90001990021B900
        :1001100002990022B90003990023B90004990024
        :10012000B90005990025B90006B90026B9000799
        :0E0130000027B90008990028C8D0D04C59FF
        :10100000A000B90000990020C8D0D0A900850085
        :1010100002A9018501A9218503B2009202E602E6
        :1010200000D0F8E603E601A501C909D0EE4C59FF
        :00000001FF
        >>> 

Getting SRecord format data out
--------------------------------
After assembling you can output the object code in S19 Srecord format.
calling the srecord() method returns lines of S19 S Record as a list.

The parameters are (int:version, int:revision, str:module name, str:comment)

>>> a.srecords(10,20,"Module Name,","Comment")
['S02e00000a144d6f64756c65204e616d652c436f6d6d656e74ad', 'S12e1000600D0C0B0AEA20A9EA96', 'S12e2000600D0C0B0AEA20A9EA86', 'S5030003f9', 'S9032008d4']

Calling the print_srecords() method outputs S19 format object code to stdout.        
>>> a.print_srecords(10,20,"Module Name,","Comment")
S02e00000a144d6f64756c65204e616d652c436f6d6d656e74ad
S12e1000600D0C0B0AEA20A9EA96
S12e2000600D0C0B0AEA20A9EA86
S5030003f9
S9032008d4

The Simulator and Disassembler
------------------------------

#The simulator is in sim6502.py, the disassembler is in dis6502.py
#So start with all three..
import asm6502
import sim6502
import dis6502

# The assembler code
src = """
<assembly goes here>
"""
lines = src.splitlines()

#The simulator must be given an object code map. You can get it from the assembler:

# Assemble the code in a list called lines.
a = asm6502.asm6502(debug=0)
a.assemble(lines)
object_code = a.object_code[:]

# Then instantiate the simulator.
# Also pass it the symbol table so it can know addresses
s = sim6502.sim6502(object_code, symbols=a.symbols)

# And instantiate the disassembler:
d = dis6502.dis6502(object_code, symbols=a.symbols)

# Now
# s.reset() will reset the simulation
# s.execute() will execute the current instruction
# The 6502 state will be in
#   s.pc  Program Counter
#   s.a   Accumulator
#   s.x   X registers
#   s.y   Y register
#   s.sp  stack pointer
#   s.cc  Flags
# d.disassemble(address) will disassemble the instruction as the address and
#                        will return a text string of the disassembly
#
# E.G.

s.reset()

print
print "SIMULATION START"
print
# Print a header for the simulator/disassembler output
print ("LABEL      " + "ADDR HEX      INSTR").ljust(status_indent)+" PC   A  X  Y  SP   Status"

# Print the initial state
print " ".ljust(status_indent) + " %04x %02x %02x %02x %04x %02x" % (s.pc,s.a,s.x,s.y,s.sp,s.cc)

# Execute 200 instructions
for i in xrange(200):
    # Disassemble the current instruction
    distxt = d.disassemble_line(s.pc)

    # Execute that instruction
    s.execute()

    # Print out the disassembled instruction followed by the simulator state
    print distxt.ljust(status_indent) + " %04x %02x %02x %02x %04x %02x" % (s.pc,s.a,s.x,s.y,s.sp,s.cc)

# Each output line will then show the address, the hex, the instruction executed and the state of the 6502 after the execution.


The Disassembler for code exploration
-------------------------------------

You can also use the disassembler for code exploration: iteratively disassembling code and then annotating labels. For example consider a file tiny.hexdump with this content:

00000100  a9 10 a2 00 9d 00 10 e8  e9 01 10 f8 60           |............`|

Now try this (see small_dis_example.py):

..
from asm6502 import asm6502
from dis6502 import dis6502

a = asm6502() # only so we can use the loader..
a.load_object_code("tiny.hexdump")

d = dis6502(a.object_code, a.symbols) # a.symbols is empty

z = d.disassemble_region(0x100, 13)
for line in list(z):
    print (line)

The call to disassemble_region() returns a generator, list() consumes its output and the 'for' loop prints it. The result looks like this:

           0100 a9 10    lda  #$10
           0102 a2 00    ldx  #$00
           0104 9d 00 10 sta  $1000,x
           0107 e8       inx  
           0108 e9 01    sbc  #$01
           010a 10 f8    bpl  $f8 ; $0104
           010c 60       rts  

Observe that there is a loop that branches to $f8 which is backwards; the helpful comment shows that the destination address is $104. Add entries to the symbol table for the entry point of the code and another for the loop destination. Whenever you manipulate the symbol table you need to call build_symbols_xref() to rebuild an internal data structure:

d.symbols['start'] = 0x100
d.symbols['loop'] = 0x104
d.build_symbols_xref()

Call disassemble_region() again as before and you will see the labels annotated into the output:

start:     0100 a9 10    lda  #$10
           0102 a2 00    ldx  #$00
loop:      0104 9d 00 10 sta  $1000,x
           0107 e8       inx  
           0108 e9 01    sbc  #$01
           010a 10 f8    bpl  loop ; $0104
           010c 60       rts  

The final trick is that the disassembler will create labels for you. You must call it twice, setting the gen_symbols argument to True. On the first call it populates the symbol table. On the second call, it annotates the symbols onto the output. Like this:

z = d.disassemble_region(0x100, 13, gen_symbols=True)
list(z)
# Build data structure
d.build_symbols_xref()
# Pass 2: and output..
z = d.disassemble_region(0x100, 13)
for line in list(z):
    print (line)

The first "list(z)" might need some explanation: because disassemble_region is a generator, it does not actually perform the calls until it is enumerated (which is done here using 'list'). The output looks like this:

           0100 a9 10    lda  #$10
           0102 a2 00    ldx  #$00
L0104:     0104 9d 00 10 sta  L1000,x
           0107 e8       inx  
           0108 e9 01    sbc  #$01
           010a 10 f8    bpl  L0104 ; $0104
           010c 60       rts  

A symbol is generated for any referenced address, provided one doesn't currently exist. This includes references outside the region being disassembled (L1000 in this example). You can mix this technique with manual manipulation of the symbol table. For example, as an iterative process of exploring a piece of unknown code.

Assembling From the Command Line
--------------------------------
The normal way to assemble code using this library is to write a python program to feed
the assembly file lines to the assembler then use the output as required.

If you want a more normal approach of using the command line to assemble a file, the
6502asm program will do that.

usage: 6502asm [-h] [-l LISTING_FILENAME] [-i IHEX_FILENAME] [-s SYMBOL_FILENAME] [-v]
               [filename]

Assembles a 6502 .asm file using py6502

positional arguments:
  filename              6502 Assembler source file .asm by default. Omit for stdin

options:
  -h, --help            show this help message and exit
  -l, --list LISTING_FILENAME
                        Filename to send listing output to. Defaults to <filename>.lst
  -i, --intelhex IHEX_FILENAME
                        Filename to send intelhex output to. Defaults to <filename>.hex
  -s, --symbol SYMBOL_FILENAME
                        Filename to send symbol table output to. Defaults to <filename>.sym
  -v, --verbose

Typical use is to feed it an assembler filename ending in .asm. It will create output 
files with the filename, replacing .asm with .lst, hex and .sym for the listing, the
intelhex output and the symbol table files.

E.G.

> 6502asm maze_10print.asm
Assembling from file: maze_10print.asm
Listing writing to:  maze_10print.lst
Symbol table written to:  maze_10print.sym
Intel Hex object code written to:  maze_10print.hex

If the output files already exist, it will create unique output filenames by incrementing
a counter in the filename.

E.G. maze_10print.lst leads to maze_10print(1).lst which leads to maze_10print(2).lst
and so on.

Making asm6502, dis6502 and sim6502 available in externally managed environments
--------------------------------------------------------------------------------

To make the python libraries available outside the source directory can be done
with pip.

> git clone https://github.com/dj-on-github/py6502
> cd py6502
> pip install .

However this won't work in many situations because the system installed python
is "externally managed". 

The method that works for me is to pass --user (to install in my user file area) and
--break-system-packages to get it to add them there even though the python being
used is externally managed.

> git clone https://github.com/dj-on-github/py6502
> cd py6502
> python3 -m pip install --user --break-system-packages .

And to get the command line 6502asm program available for the local user:
> cp src/6502asm ~/bin

or for site wide, put it whereever system wide binaries go.

--------------------------------------------
Comments to dj@deadhat.com

--------------------------------------------
TBD 1: Write a 65C02 simulator that runs from the object_code state generated by the assembler
  DONE!

TBD 2: Write an output generator for more of the flash/prom/eeprom programming formats
  Added Srecords. Should also add ascii hex and binary.
DONE!

TBD 3: Give it decent error handling
Well it's better, but I wouldn't call it "good".

TBD 4: Set up a unit test bench to fuzz it with code and do directed tests.
Done!

About

A Python 6502 Assembler/Disassembler/simulator in which python serves in place of the a macro language.

Resources

License

Stars

39 stars

Watchers

5 watching

Forks

Packages

 
 
 

Contributors