A Motorola 68000 emulator written in Go.
This project provides a Motorola 68000 CPU emulator for retro-computing projects, with a current focus on becoming part of an Atari ST emulator. The core aims to be timing-aware, testable, and easy to embed in a larger machine model.
- Motorola 68000 instruction set emulation.
- Timing-aware execution with per-instruction cycle accounting.
- Supervisor and user modes.
- Interrupt handling and exception processing.
- Correct short exception frames for group 1/2 exceptions and 68000 group 0 bus/address error frames.
- 24-bit address bus with support for multiple devices, fixed-range mappings, and Atari ST-style region layout.
- Tracing, breakpoints, cycle-budgeted execution, and verbose logging helpers with instruction-range disassembly.
- Rich debug hooks for per-instruction trace, pre-instruction snapshots, exceptions, bus accesses, and accepted interrupts.
-
RunUntilstop conditions for instruction budgets, exact PC stops, PC ranges, exceptions, bus-access matches, and custom predicates. - Optional rolling debug history plus helpers to inspect the last exception stack frame.
- Optional cycle scheduler hooks for machine-level devices such as timers, video, DMA, and interrupt controllers.
The CPU core is in good shape for integration work:
- Instruction execution, stack behavior, interrupts, and most commonly used addressing modes are covered by tests.
-
RESETnow follows machine-friendly semantics for an Atari ST integration: the CPU instruction resets attached devices but does not erase RAM contents. - Bus and address faults now use the richer 68000 group 0 stack frame, which is important for realistic system error handling.
- The bus has fast paths for simple memory setups and fixed-range mappings, which keeps the core practical for full-machine emulation.
Still missing for a complete Atari ST:
- Prefetch-sensitive behavior and any remaining compatibility gaps found by larger TOS / software workloads.
This package is designed to be used as a library in your own projects.
The repository also includes a small example command in cmd/qsortdemo, which assembles and executes the testdata/qsort.s quicksort demo.
This module targets Go 1.26.
go get github.com/jenska/m68kemuIf you want the demo binary, install it directly:
go install github.com/jenska/m68kemu/cmd/qsortdemo@latestHere's a simple example of how to set up the CPU, load a program, and run it:
package main
import (
"fmt"
"log"
"github.com/jenska/m68kemu"
)
func main() {
// Create a 64KB RAM device at address 0.
ram := m68kemu.NewRAM(0, 64*1024)
// Create a bus and attach the RAM.
bus := m68kemu.NewBus(ram)
// Set up the initial stack pointer and program counter.
// SSP at 0x1000, PC at 0x2000.
ram.Write(m68kemu.Long, 0, 0x1000)
ram.Write(m68kemu.Long, 4, 0x2000)
// Create the CPU.
cpu, err := m68kemu.NewCPU(bus)
if err != nil {
log.Fatalf("Failed to create CPU: %v", err)
}
// Assemble a simple program: MOVEQ #5, D0 (opcode 0x7005)
program := []byte{0x70, 0x05}
startPC, _ := ram.Read(m68kemu.Long, 4)
for i, b := range program {
if err := ram.Write(m68kemu.Byte, startPC+uint32(i), uint32(b)); err != nil {
log.Fatalf("Failed to write program: %v", err)
}
}
// Step one instruction.
if err := cpu.Step(); err != nil {
log.Fatalf("CPU step failed: %v", err)
}
// Print registers to see the result.
regs := cpu.Registers()
fmt.Printf("D0 = %d\n", regs.D[0]) // Should be 5
fmt.Printf("PC = 0x%04x\n", regs.PC) // Should be 0x2002
}Machine devices can follow CPU time by attaching a scheduler:
scheduler := m68kemu.NewCycleScheduler()
cpu.SetScheduler(scheduler)
scheduler.ScheduleAfter(512, func(now uint64) {
// Run a timer tick, trigger an interrupt, advance video state, etc.
})The scheduler is intentionally small at this stage. It is meant as a foundation for ST components rather than a finished machine-timing framework.
The emulator includes helpers for both one-off disassembly and trace logging:
logger := m68kemu.NewVerboseLogger(cpu, bus, os.Stdout, m68kemu.VerboseLoggerOptions{
IncludeRegisters: true,
IncludeCycles: true,
MemoryRanges: []m68kemu.MemoryRange{
{Start: 0x2000, Length: 0x10, Label: "program"},
},
})
cpu.SetTracer(logger.Trace)
lines, err := m68kemu.DisassembleMemoryRange(bus, 0x2000, 0x10)
if err != nil {
log.Fatalf("disassembly failed: %v", err)
}
for _, line := range lines {
fmt.Println(line)
}Verbose trace lines include the current PC, decoded assembly, and optionally the total cycle count. When the tracer has access to the fetched instruction bytes, the logger also includes the raw opcode and per-instruction cycle delta, for example:
TRACE PC 00002000 OPCODE 7005 DELTA 4 CYCLES 4 MOVEQ #5, D0
These helpers use the bus Peek path when available so debug output does not trigger device side effects, and the verbose logger prefers TraceInfo.Bytes for disassembly so the trace remains accurate even when fetch-side effects would make a second bus read misleading.
For emulator bring-up and TOS failure analysis, the CPU exposes several debugger-oriented callbacks:
cpu.SetPreTracer(func(info m68kemu.PreTraceInfo) {
// Inspect registers before the instruction executes.
})
cpu.SetTracer(func(info m68kemu.TraceInfo) {
// Instruction address, opcode bytes, mnemonic, before/after registers,
// per-instruction cycle delta, and total cycle count.
})
cpu.SetExceptionTracer(func(info m68kemu.ExceptionInfo) {
// Vector, trapping opcode address, stacked/reported PC, SR before/after,
// new handler PC, and decoded stack-frame details.
})
cpu.SetBusTracer(func(info m68kemu.BusAccessInfo) {
// Address, size, read/write, value, instruction-fetch flag, and current instruction PC.
})
cpu.SetInterruptTracer(func(info m68kemu.InterruptInfo) {
// Accepted IRQ level, vector, autovector/explicit, and PC/SR at acceptance.
})RunUntil can also stop on richer conditions:
result, err := cpu.RunUntil(m68kemu.RunUntilOptions{
MaxInstructions: 1000,
StopAtPC: []uint32{0x00fc1234},
StopOnException: true,
StopOnBusAccess: func(info m68kemu.BusAccessInfo) bool {
return !info.InstructionFetch && info.Address == 0x00ff8209
},
StopPredicate: func(info m68kemu.RunPredicateInfo) bool {
return info.Registers.D[0] == 0xdeadbeef
},
})If you want a rolling "what just happened?" buffer without always logging, call cpu.SetHistoryLimit(n) and inspect cpu.History(). After an exception, cpu.CurrentExceptionFrame() and m68kemu.ReadExceptionStackFrame(...) can decode the pushed 68000 frame directly from memory.
The emulator has an extensive test suite, including instruction-level tests and small programs.
To run the tests:
go test ./...To run the benchmarks:
go test -bench=. ./...To run the core CPU and infrastructure benchmarks without test noise:
go test -run '^$' -bench 'Benchmark(BubbleSort|PrimeSieve|RunEightMillionCycles|RecursiveFibonacci|CycleSchedulerAdvanceBurst|BusReadMappedRanges)$' -benchmem ./...Recent profiling work focused on the interpreter hot path:
- bus fast paths for simple and fixed-range mappings
- cached single-RAM fast path when the bus has no wait-state devices
- precomputed page-range lookup for mapped devices
- amortized scheduler event dispatch without per-event slice shifting
- reduced wait-state overhead when no device contributes extra wait states
- fewer allocations and less debug bookkeeping in normal benchmark loops
- predecoded opcode metadata for common decode fields
- Go 1.26 benchmark loops using
testing.B.Loop
Representative results on June 13, 2026 on Apple M1 (darwin/arm64, Go 1.26.3) were:
-
BenchmarkBubbleSort: ~2.54 ms/op -
BenchmarkPrimeSieve: ~4.98 ms/op -
BenchmarkRunEightMillionCycles: ~25.6 ms/op -
BenchmarkRecursiveFibonacci: ~26.5 ms/op -
BenchmarkCycleSchedulerAdvanceBurst: ~3.29 us/op -
BenchmarkBusReadMappedRanges: ~15.5 ns/op
See doc/benchmark_report.md for more detail.
This project is licensed under the MIT License - see the LICENSE file for details.