diff --git a/src/cpu/mod.rs b/src/cpu/mod.rs
index 639411f..55e3331 100644
--- a/src/cpu/mod.rs
+++ b/src/cpu/mod.rs
@@ -1,11 +1,20 @@
+use crate::trace::TraceHandler;
+
 pub mod mos6502;
 
 pub trait Cpu {
+  /// Reset this CPU, clearing internal state.
   fn reset(&mut self);
 
+  /// Attach the given handler to receive trace events from this CPU.
+  fn attach_trace_handler(&mut self, trace: Box<dyn TraceHandler>);
+
   /// Return the number of cycles elapsed since the system last reset.
   fn get_cycle_count(&self) -> u64;
 
   /// Execute a single instruction. Return the number of cycles elapsed.
   fn tick(&mut self) -> u8;
+
+  /// Clean up any resources used by this CPU.
+  fn cleanup(&mut self) -> Result<(), &str>;
 }
diff --git a/src/cpu/mos6502/mod.rs b/src/cpu/mos6502/mod.rs
index 347aa38..c1af25a 100644
--- a/src/cpu/mos6502/mod.rs
+++ b/src/cpu/mos6502/mod.rs
@@ -2,6 +2,7 @@ mod execute;
 mod fetch;
 mod registers;
 use crate::memory::{ActiveInterrupt, Memory};
+use crate::trace::{CpuTrace, TraceHandler};
 use execute::Execute;
 use fetch::Fetch;
 use registers::{flags, Registers};
@@ -25,6 +26,7 @@ pub struct Mos6502 {
   cycle_count: u64,
   cycles_since_poll: u64,
   variant: Mos6502Variant,
+  trace: Option<Box<dyn TraceHandler>>,
 }
 
 /// Read and write from the system's memory.
@@ -144,6 +146,7 @@ impl Mos6502 {
       cycle_count: 0,
       cycles_since_poll: 0,
       variant,
+      trace: None,
     }
   }
 }
@@ -161,9 +164,21 @@ impl Cpu for Mos6502 {
     self.cycle_count
   }
 
+  fn attach_trace_handler(&mut self, trace: Box<dyn TraceHandler>) {
+    self.trace = Some(trace);
+  }
+
   /// Execute a single instruction.
   fn tick(&mut self) -> u8 {
     let opcode = self.fetch();
+
+    if let Some(tracer) = &mut self.trace {
+      tracer.handle(&CpuTrace {
+        address: self.registers.pc.address(),
+        opcode,
+      });
+    }
+
     match self.execute(opcode) {
       Ok(cycles) => {
         self.cycle_count += cycles as u64;
@@ -192,4 +207,12 @@ impl Cpu for Mos6502 {
       }
     }
   }
+
+  fn cleanup(&mut self) -> Result<(), &str> {
+    if let Some(tracer) = &mut self.trace {
+      tracer.flush()
+    } else {
+      Ok(())
+    }
+  }
 }
diff --git a/src/lib.rs b/src/lib.rs
index d4fe3ee..401f648 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -31,6 +31,9 @@ pub mod roms;
 /// Systems are created by a [`systems::SystemBuilder`]. A system is created with some roms, configuration, and platform. For instance, the `build` implementation on [`systems::pet::PetSystemBuilder`] takes in [`systems::pet::PetSystemRoms`], [`systems::pet::PetSystemConfig`], and an `Arc<dyn PlatformProvider>`.
 pub mod systems;
 
+/// Traces log the state of the system as it runs (e.g., to a file). This is useful for debugging.
+pub mod trace;
+
 mod time;
 
 #[cfg(target_arch = "wasm32")]
diff --git a/src/main.rs b/src/main.rs
index a3f7814..309fc8f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -51,11 +51,16 @@ struct Args {
 
   #[clap(short, long, value_parser, default_value = "symbolic")]
   key_mapping: KeyMappingArg,
+
+  #[clap(short, long, value_parser, default_value = "false")]
+  trace: bool,
 }
 
 #[cfg(not(target_arch = "wasm32"))]
 fn main() {
-  use libnoentiendo::{cpu::mos6502::Mos6502Variant, systems::klaus::KlausSystemConfig};
+  use libnoentiendo::{
+    cpu::mos6502::Mos6502Variant, systems::klaus::KlausSystemConfig, trace::file::FileTraceHandler,
+  };
 
   let args = Args::parse();
 
@@ -74,7 +79,7 @@ fn main() {
     KeyMappingArg::Physical => KeyMappingStrategy::Physical,
   };
 
-  let system = match args.system {
+  let mut system = match args.system {
     SystemArg::Basic => BasicSystem::build(romfile.unwrap(), (), platform.provider()),
     SystemArg::Easy => Easy6502System::build(romfile.unwrap(), (), platform.provider()),
     SystemArg::Klaus => KlausSystem::build(
@@ -105,5 +110,9 @@ fn main() {
     ),
   };
 
+  if args.trace {
+    system.attach_trace_handler(Box::new(FileTraceHandler::new("./cpu.trace".to_owned())));
+  }
+
   platform.run(system);
 }
diff --git a/src/systems/mod.rs b/src/systems/mod.rs
index 3f7e986..e5097f7 100644
--- a/src/systems/mod.rs
+++ b/src/systems/mod.rs
@@ -1,6 +1,7 @@
 use crate::{
   cpu::Cpu,
   platform::{PlatformProvider, WindowConfig},
+  trace::TraceHandler,
 };
 use instant::Duration;
 use std::sync::Arc;
@@ -27,6 +28,10 @@ pub trait System {
   /// Return a mutable reference to the CPU used in this system.
   fn get_cpu_mut(&mut self) -> Box<&mut dyn Cpu>;
 
+  fn attach_trace_handler(&mut self, handler: Box<dyn TraceHandler>) {
+    self.get_cpu_mut().attach_trace_handler(handler);
+  }
+
   /// Advance the system by one tick.
   fn tick(&mut self) -> Duration;
 
@@ -38,6 +43,6 @@ pub trait System {
 
   /// Clean up any resources used by this system.
   fn cleanup(&mut self) -> Result<(), &str> {
-    Ok(())
+    self.get_cpu_mut().cleanup()
   }
 }
diff --git a/src/trace/file.rs b/src/trace/file.rs
new file mode 100644
index 0000000..559602c
--- /dev/null
+++ b/src/trace/file.rs
@@ -0,0 +1,30 @@
+use crate::trace::{CpuTrace, TraceHandler};
+use std::{
+  fs::File,
+  io::{BufWriter, Write},
+};
+
+pub struct FileTraceHandler {
+  file: BufWriter<File>,
+}
+
+impl FileTraceHandler {
+  pub fn new(filename: String) -> Self {
+    Self {
+      file: BufWriter::new(File::create(filename).expect("Invalid filename")),
+    }
+  }
+}
+
+impl TraceHandler for FileTraceHandler {
+  fn handle(&mut self, trace: &CpuTrace) {
+    self
+      .file
+      .write_all(format!("{:04X}: {:02X}\n", trace.address, trace.opcode).as_bytes())
+      .unwrap();
+  }
+
+  fn flush(&mut self) -> Result<(), &str> {
+    self.file.flush().map_err(|_| "failed to flush file")
+  }
+}
diff --git a/src/trace/mod.rs b/src/trace/mod.rs
new file mode 100644
index 0000000..a1b354e
--- /dev/null
+++ b/src/trace/mod.rs
@@ -0,0 +1,19 @@
+#[cfg(not(target_arch = "wasm32"))]
+pub mod file;
+
+/// Trace information provided after each instruction by the CPU.
+pub struct CpuTrace {
+  pub address: u16,
+  pub opcode: u8,
+}
+
+/// An item which can handle a CPU trace (e.g. logging to a file)
+pub trait TraceHandler {
+  /// Handle a trace event.
+  fn handle(&mut self, trace: &CpuTrace);
+
+  /// Flush any existing resource buffers.
+  fn flush(&mut self) -> Result<(), &str> {
+    Ok(())
+  }
+}