author |
Steve Losh <steve@stevelosh.com> |
date |
Mon, 09 Sep 2024 10:31:31 -0400 |
parents |
f5556130bda1 |
children |
(none) |
(
:title "CHIP-8 in Common Lisp: Debugging Infrastructure"
:snip "What's happening inside this computer?"
:date "2017-01-05T16:40:00Z"
:draft nil
)
Our [CHIP-8][] emulator in Common Lisp is coming along nicely. It can play
games, and in the last post we added a disassembler so we can dump the code of
ROMs.
In this post we'll add some low-level debugging infrastructure so we can set
breakpoints and step through code.
The full series of posts so far:
1. [CHIP-8 in Common Lisp: The CPU](http://stevelosh.com/blog/2016/12/chip8-cpu/)
2. [CHIP-8 in Common Lisp: Graphics](http://stevelosh.com/blog/2016/12/chip8-graphics/)
3. [CHIP-8 in Common Lisp: Input](http://stevelosh.com/blog/2016/12/chip8-input/)
4. [CHIP-8 in Common Lisp: Sound](http://stevelosh.com/blog/2016/12/chip8-sound/)
5. [CHIP-8 in Common Lisp: Disassembly](http://stevelosh.com/blog/2017/01/chip8-disassembly/)
6. [CHIP-8 in Common Lisp: Debugging Infrastructure](http://stevelosh.com/blog/2017/01/chip8-debugging-infrastructure/)
7. [CHIP-8 in Common Lisp: Menus](http://stevelosh.com/blog/2017/01/chip8-menus/)
The full emulator source is on [BitBucket][] and [GitHub][].
[CHIP-8]: https://en.wikipedia.org/wiki/CHIP-8
[BitBucket]: https://bitbucket.org/sjl/cl-chip8
[GitHub]: https://github.com/sjl/cl-chip8
<div id="toc"></div>
## Architecture
The overall goal will be to keep the debugging infrastructure as separate from
the rest of the emulation code as possible. Unfortunately the nature of
debugging will require us to weave it into *some* of the normal emulator code,
but we'll try to keep the pollution to a minimum.
All information about the state of the debugger (e.g. breakpoints, pause status,
etc) will be stored in a separate debugger data structure. We'll define a small
API for interacting with this structure. The `chip` struct will have
a `debugger` slot and will use the debugger API to interact with it. Later the
graphical debugger UI will also use this API.
## The Debugger Data Structure
We'll start by creating a `debugger` struct and a `with-debugger` macro for it
and will add fields to this struct as we build the debugging infrastructure:
```lisp
(defstruct debugger
; ...
)
(define-with-macro debugger
; ...
)
```
We'll then add a `debugger` slot to our `chip` struct:
```lisp
(defstruct chip
; ...
(debugger (make-debugger) :type debugger :read-only t))
```
It's read-only because we should never be swapping out a `chip`'s debugger as it
runs.
## Pausing
The first thing we'll add is support for pausing and unpausing execution. We'll
add a `paused` slot to the debugger:
```lisp
(defstruct debugger
(paused nil :type boolean))
```
Then we'll make some simple API functions so the emulator won't have to directly
work with the debugger's slots:
```lisp
(defun debugger-pause (debugger)
(with-debugger (debugger)
(setf paused t)))
(defun debugger-unpause (debugger)
(with-debugger (debugger)
(setf paused nil)))
(defun debugger-paused-p (debugger)
(debugger-paused debugger))
(defun debugger-toggle-pause (debugger)
(if (debugger-paused-p debugger)
(debugger-unpause debugger)
(debugger-pause debugger)))
```
Notice how `debugger-toggle-pause` uses the lower-level API functions instead of
directly modifying the slot. This will become important shortly.
Now we need to start modifying the emulator itself to pause execution when the
debugger is paused. Unfortunately this is going to be pretty invasive, but
I don't think there's much of a way around that.
We'll change `emulate-cycle` to check if the debugger is paused before actually
running an instruction:
```lisp
(defun emulate-cycle (chip)
(with-chip (chip)
(if (debugger-paused-p debugger) ; NEW
(sleep 10/1000) ; NEW
(let ((instruction (cat-bytes
(aref memory program-counter)
(aref memory (1+ program-counter)))))
(zapf program-counter (chop 12 (+ % 2)))
(dispatch-instruction chip instruction)))
nil))
```
The timer thread will need to skip the timer decrements whenever the debugger is
paused:
```lisp
(defun run-timers (chip)
(with-chip (chip)
(iterate
(while running)
(when (not (debugger-paused-p debugger)) ; NEW
(decrement-timers chip))
(sleep 1/60))))
```
Next we'll modify the sound thread to be silent when paused, otherwise pausing
during a buzz would result in perpetual buzzing:
```lisp
(defun run-sound (chip)
(portaudio:with-audio
(portaudio:with-default-audio-stream
(audio-stream 0 1
:sample-format :float
:sample-rate +sample-rate+
:frames-per-buffer +audio-buffer-size+)
(with-chip (chip)
(iterate (with buffer = (make-audio-buffer))
(with angle = 0.0)
(with rate = (audio-rate 440))
(while running)
(if (and (plusp sound-timer)
(not (debugger-paused-p debugger))) ; NEW
(progn
(setf angle (funcall (audio-buffer-filler chip)
buffer rate angle))
(portaudio:write-stream audio-stream buffer))
(sleep +audio-buffer-time+))))))
nil)
```
We'll also need a way to actually pause and unpause the debugger. We could do
it through NREPL or SLIME, but it'll be easier if we just add a key for it over
on the Qt side of things:
```lisp
(define-override (screen key-release-event) (ev)
(let* ((key (q+:key ev))
(pad-key (pad-key-for key))
(debugger (chip8::chip-debugger chip))) ; NEW
(if pad-key
(chip8::keyup chip pad-key)
(qtenumcase key
((q+:qt.key_escape) (die screen))
((q+:qt.key_f1) (chip8::reset chip))
; NEW
((q+:qt.key_space) (chip8::debugger-toggle-pause debugger)
(t (pr :unknown-key (format nil "~X" key))))))
(stop-overriding))
```
Now we can pause and unpause the emulator with the space bar, which is a handy
feature to have all on its own.
## Stepping
The next step is adding support for single-stepping through the code. We'll
start by adding a slot to the `debugger`:
```lisp
(defstruct debugger
; ...
(take-step nil :type boolean))
```
Then we'll add an API function to set it:
```lisp
(defun debugger-step (debugger)
(setf (debugger-take-step debugger) t))
```
Now we need to wire this into the emulator, which is a bit tricky. We need to
handle the following cases:
* The debugger isn't paused at all, just run normally.
* The debugger is paused, but `take-step` is false, so just wait.
* The debugger is paused and `take-step` is true, so take a single step and then
pause after that.
We'll encapsulate this in a separate API function with a decidedly mediocre
name. `debugger-check-wait` will be run before each cycle, and will return `t`
whenever execution should wait. It will handle checking and updating
`take-step` as needed:
```lisp
(defun debugger-check-wait (debugger)
"Return `t` if the debugger wants execution to wait, `nil` otherwise."
(with-debugger (debugger)
(cond
;; If we're not paused, just run normally.
((not paused) nil)
;; If we're paused, but are ready to step, run just this once.
(take-step (setf take-step nil)
nil)
;; Otherwise we're fully paused, so wait.
(t t))))
```
I didn't name it with a `-p` suffix because although it qualifies as a predicate
according to the Common Lisp spec ("a function that returns a generalized
boolean as its first value") it has side effects, and I generally don't expect
predicates to modify state.
We can wire this into `emulate-cycle`, replacing the vanilla
`debugger-paused-p`:
```lisp
(defun emulate-cycle (chip)
(with-chip (chip)
(if (debugger-check-wait debugger) ; NEW
(sleep 10/1000)
(let ((instruction (cat-bytes
(aref memory program-counter)
(aref memory (1+ program-counter)))))
(zapf program-counter (chop 12 (+ % 2)))
(dispatch-instruction chip instruction)))
nil))
```
Now pausing will work as before, but whenever we set `take-step` to `t` the CPU
will emulate one more cycle before going back to being paused. We can add a key
to the Qt screen to request a step:
```lisp
(define-override (screen key-release-event) (ev)
(let* ((key (q+:key ev))
(pad-key (pad-key-for key))
(debugger (chip8::chip-debugger chip)))
(if pad-key
(chip8::keyup chip pad-key)
(qtenumcase key
((q+:qt.key_escape) (die screen))
((q+:qt.key_f1) (chip8::reset chip))
; NEW
((q+:qt.key_f7) (chip8::debugger-step debugger)
((q+:qt.key_space) (chip8::debugger-toggle-pause debugger)
(t (pr :unknown-key (format nil "~X" key))))))
(stop-overriding))
```
Now we can walk through the code one instruction at a time by pausing the
emulator and hitting `F7` to take a step. I picked `F7` because it matches the
"take step" key for another emulator I use.
This is all great, but pretty useless unless we can also see what instruction is
about to run.
## Printing
Handling the printing is going to be ugly. We want to print the disassembly of
the current instruction whenever we "arrive" at a new instruction. This will
happen:
* When we first pause the debugger.
* After a single step has been taken.
We'll try to keep things as clean as possible on the emulator side of things by
containing all the ugliness in a `debugger-arrive` function, which we'll call at
the start of every possible cycle:
```lisp
(defun emulate-cycle (chip)
(with-chip (chip)
(debugger-arrive debugger chip) ; NEW
(if (debugger-check-wait debugger program-counter)
(sleep 10/1000)
(let ((instruction (cat-bytes
(aref memory program-counter)
(aref memory (1+ program-counter)))))
(zapf program-counter (chop 12 (+ % 2)))
(dispatch-instruction chip instruction)))
nil))
```
Now we can handle the ugliness on the debugger side. We'll add a slot for
tracking when we're waiting to arrive:
```lisp
(defstruct debugger
; ...
(awaiting-arrival nil :type boolean))
```
Then we'll update our API to set this flag properly:
```lisp
(defun debugger-pause (debugger)
(with-debugger (debugger)
(setf paused t
awaiting-arrival t))) ; NEW
(defun debugger-unpause (debugger)
(with-debugger (debugger)
(setf paused nil
awaiting-arrival nil))) ; NEW
(defun debugger-check-wait (debugger)
"Return `t` if the debugger wants execution to wait, `nil` otherwise."
(with-debugger (debugger)
(cond
;; If we're not paused, just run normally.
((not paused) nil)
;; If we're paused, but are ready to step, run just this once.
(take-step (setf take-step nil
awaiting-arrival t) ; NEW
nil)
;; Otherwise we're fully paused, so wait.
(t t))))
```
We didn't have to touch `debugger-toggle-pause` because it uses the lower-level
API functions, so everything works properly.
Now we can implement `debugger-arrive`:
```lisp
(defun debugger-arrive (debugger chip)
(with-debugger (debugger)
(when awaiting-arrival
(setf awaiting-arrival nil)
(debugger-print debugger chip))))
```
Finally we can implement the actual instruction-printing function, which is
trivial thanks to the work we did in the previous post:
```lisp
(defun debugger-print (debugger chip)
(declare (ignore debugger))
(print-disassembled-instruction (chip-memory chip)
(chip-program-counter chip)))
```
Now we can run the emulator, press `space` to pause and we'll see the current
instruction (the one about to be executed) dumped to the console. We can press
`F7` to step one instruction at a time and they'll each be dumped in turn:
[![Screenshot of CHIP-8 stepping](/static/images/blog/2017/01/chip8-step.png)](/static/images/blog/2017/01/chip8-step.png)
## Breakpoints
Now that we can pause and step we can start tracking down bugs in the emulator.
We'll want to add breakpoints so that we don't need to manually step until we
get to a problematic instruction.
We'll start by adding a slot to store the breakpoint addresses, as well as API
functions for adding and removing them:
```lisp
(defstruct debugger
; ...
(breakpoints nil :type list))
(defun debugger-add-breakpoint (debugger address)
(pushnew address (debugger-breakpoints debugger)))
(defun debugger-remove-breakpoint (debugger address)
(removef (debugger-breakpoints debugger) address))
```
`removef` is from Alexandria and is just a `modify-macro` for `remove`.
Next we'll create a `debugger-check-breakpoints` function that will check
whether we're at a breakpoint, and pause the debugger if so. It will return `t`
if we're at a breakpoint:
```lisp
(defun debugger-check-breakpoints (debugger address)
"Return `t` if the debugger is at a breakpoint, `nil` otherwise."
(if (member address (debugger-breakpoints debugger))
(progn (debugger-pause debugger)
t)
nil))
```
Note that we use the `debugger-pause` API function here to make sure we handle
setting the `paused` and `awaiting-arrival` slots properly. Now we can modify
`debugger-check-wait` to use this function. We'll also need to update it to
take the current instruction's address:
```lisp
; NEW
(defun debugger-check-wait (debugger address)
"Return `t` if the debugger wants execution to wait, `nil` otherwise."
(with-debugger (debugger)
(cond
;; If we're not paused, we might be at a breakpoint. ; NEW
((not paused) (debugger-check-breakpoints debugger ; NEW
address)) ; NEW
;; If we're paused, but are ready to step, run just this once.
(take-step (setf take-step nil
awaiting-arrival t)
nil)
;; Otherwise we're fully paused, so wait.
(t t))))
```
And that's it, nothing else needs to change. We can add breakpoints to the
currently running `chip` in the REPL with something like
`(debugger-add-breakpoint (chip-debugger *c*) #x200)`. This isn't the nicest
interface, but it'll do the job for now.
## Result
We've implemented basic pausing, single-stepping, and breakpoints. There are
a lot more features we could add to the debugger, and we'll cover some in later
posts.
Creating a debugging interface isn't the most glamorous work, but it pays for
itself the first time you run into a bug in the emulator.
I'm not entirely happy with all the coupling between the emulator and the
debugger, but I'm not sure it's possible to completely untangle the two. If
you've got suggestions for how to design the interface more cleanly please let
me know.
## Future
We're nearing the end of this series, but there are a couple more things I'll
cover before it's over:
* A graphical interface to the debugger and disassembler.
* Polishing up the interface a bit with menus for loading ROMs, configuring
options, etc.