GitHub - KallDrexx/Dotnet6502: Just in time compilation and execution of 6502 applications

8 min read Original article ↗

This project allows the just-in-time compilation and execution of applications compiled to 6502 assembly.

How Does It Work?

The JIT process contains the following steps:

  1. Disassemble and trace out the instructions
  2. Convert the 6502 instructions into an intermediate representation
  3. Performs any analysis or customizations on the IR instructions
  4. Convert the intermediate representation into .net MSIL
  5. Create a dynamic assembly with the generated MSIL
  6. Execute the created method

Disassembly

When the JIT compiler is given the address of an instruction to run, it pulls all code eligible memory regions from the memory bus and passes them (and the address of the first instruction) to the decompiler.

The decompiler starts disassembling and tracing the 6502 instructions starting from that address in the memory regions until all branches terminate in a loop or a function boundary. Any RTS, RTI, BRK, JSR, or indirect JMP instruction is considered the end of a function.

An invalid 6502 instruction is also considered the end of a function, as there are times when an unconditional branch instruction is used instead of a JMP instruction to save bytes/cycles.

Once this is complete, we have the full set of disassembled instructions that comprise the provided function.

Conversion To IR

There are 56 official instructions in the 6502 assembly instruction set. Some are simple while others are complex. Many of them rely on a variety of access patterns depending on if they are fetching or modifying registers, processor flags, or memory values. Attempting to write executable instructions for each of these is complex, error-prone, hard to debug, and gives very little ability for optimizations and analysis.

Instead, it turns out that all 56 operations can be represented by combining ~12 smaller intermediate representation instructions.

This step of the JIT process takes the 6502 disassembled instructions and converts each one into one or more IR instructions.

IR Analysis And Customization

Now that we have a full set of IR instructions, we can perform some analysis on them and customize them as needed.

This is done via a IJitCustomizer interface that allows different hardware emulation systems to add or remove instructions. There is a standard JIT customizer which prepends a debugging hook and a poll for interrupts prior to the instruction execution.

The NES example further adds a custom instruction to increment cycle counts.

MSIL Generation

Once the final set of IR instructions are available, we can then generate MSIL for each of them.

Assembly Generation

The generated MSIL is placed within its own static method in a static type in its own dynamic assembly.

The containing type is then compiled by the .net runtime and a delegate is created that we can then execute

Now that we have a delegate containing the compiled code, we instruct the JIT to execute the delegate, thereby running the 6502 application. The delegate will run until it returns with an address to execute next, at which point the JIT will repeat the process for the returned address.

It assumes a second call to an address that's already been compiled is for the same function, and therefore will re-use delegates that it has previously compiled

Cache Management

Since all memory writes go through the hardware abstraction layer, the HAL notifies the JIT compiler for every memory address that has been changed. The JIT compiler then evicts the previously cached instance of the function from the cache. The function will be freshly decompiled and recompiled into MSIL the next time it is invoked.

Self Modifying Code

Many 6502 programs make use of self modifying code, where the function will update its own instructions.

The JIT system knows the address of each instruction it has created MSIL code for. So when it gets notified of a memory change event for an instruction in the currently executing function, it will mark the address of the currently executing instruction as a known self modifying code source address. This will then trigger an immediate exit of the current function and a re-entry (and recompile) at the next address.

On the next recompile, the addresses of known self modifying code targets are passed in. If the target of self modifying code is an operand of some specific instructions (e.g. LDA), then we know how to handle the SMC scenario. In that case we make the operand handling operate dynamically instead of hard coded in the instruction's internal representation and mark that SMC target as "handled".

If all SMC targets for a function have been handled then we compile the function into pure MSIL and even SMC executions will not cause a recompile of the function, and the function operates successfully. This handles a good number of SMC cases.

If there are SMC targets that are not marked as handled, then instead of converting it to MSIL we instead run the function through an interpreter. That allows SMC heavy functions that we can't handle without a recompile to not have the .net runtime JIT cost for every modification that happens to the function, while keeping non-SMC'ed functions using the faster JIT system.

Creating An Emulator

To emulate a 6502 based system:

  1. Create IMemoryDevice implementations devices for all memory mapped regions
  2. Populate any required memory devices with the program ROM you are intended to execute.
    • Most 6502 applications tend to have the application code loaded towards the end of the memory range.
  3. Instantiate a MemoryBus and attach all memory devices to their respective addresses.
    • This handles all memory reads and writes and maps them to the correct offset to the expected memory mapped device.
  4. Instantiate a Hardware Abstraction Layer instance.
    • This contains all CPU registers and passes memory read and write calls to the memory bus.
    • A custom implementation will be needed to ensure hardware interrupts are triggered.
    • It is a good idea for a custom implementation to take in a CancellationToken, so you can stop execution as needed during PollForInterrupt.
  5. Create a IJitCustomizer implementation if JIT customizations are required.
  6. Instantiate a JitCompiler instance.
  7. Call JitCompiler.RunMethod() with the address of the initial function to execute.
    • Most 6502 applications store this address in $FFFC and $FFFD.

The JIT will now run and execute the program, usually forever. You can call RunMethod() in a background thread.

You'll want to have some way to synchronize the 6502 code with the system somehow. Many 6502 devices have a display that always runs at 60Hz, and so you can use the trigger of VBlank on the display emulator you create to pause the 6502 code for 16ms. This ensures only 1 frame of 6502 worth of assembly executes within a 16ms window.

The disassembly instructions do have cycle counts with them, so pure cycle counting + sleep is also a viable option.

Example Implementations

NES Emulator

The Dotnet6502.Nes.Cli and Dotnet6502.Nes projects contain an implementation of the 6502 Just-In-Time compilation system to execute NES roms. Monogame is used for the window and input handling mechanism.

To play a NES game, obtain the ROM you wish to play and run: dotnet run --project src/Dotnet6502.Nes.Cli/Dotnet6502.Nes.Cli --rom <path-to-rom>. A window should pop up.

The controls are:

  • Arrow keys for directional input
  • Enter - Start
  • Backspace - Select
  • Z - A button
  • X - B button

Note that not all roms will work with the emulator. Custom memory mapping hardware has not been implemented, so any games that are larger than 32KB will not map correctly. Likewise, any game with more than 16KB of character ROM will not work either.

A good example homebrew game is Alter Ego.

Commodore 64 Emulator

The Dotnet6502.C64 project contains an implementation of the 6502 JIT system to execute the Commodore 64 operating system.

You will need a C64 BASIC, Kernel, and Character rom binaries. Once you have them you can run the emulator via

dotnet run -- \
  --char <character-rom-location> \
  --basic <basic-rom-location> \
  --kernel <kernel-rom-location> 

If you have a specific disk image in either the PRG or D64 format you can have them loaded via the --d64 <d64-filelocation> or --prg <prg-file-location> arguments.

Note that the keyboard mapping does not follow normal keyboard layout standards. While letters and numbers are correct for modern keyboards, symbols and shift values instead match the original Commodore 64 keyboard layout (e.g. [ maps to =]).

Note that not all games and applications work.