As I was finishing up my assembler, I knew I was going to need a way to save
assembled programs off the Altair so I starting working on a new bootloader-type
program. Writing, assembling, and testing revealed a new problem of trying to
debug programs that I don't have the hand assembled version of.
Without hand assembling, I don't easily know where instructions will land in
memory or what the value of the instructions will be. Before, when I had a
problem, I could look at my assembled program and use the front panel to go to
where I though the problem was. Usually I'd replace an instruction with HLT and
execute the program to that point. Then remove the HLT and put the original
instruction back and single step from there.
But, now that I have an assembler doing all the work for me, it was suddenly
really easy to insert a CALL to a debug subroutine. I can put the CALL where I
think the code goes awry, reassemble and execute it just like putting the HLT in
via the front panel.
I wrote the subroutine into the monitor I was working on so it would get
assembled with it and I could CALL it by a label. The subroutine could also
reuse the existing serial IO routines.
The debug subroutine prints out the Stack Pointer and Program Counter from
before the CALL to debug, and all of the CPU registers. Upon RET, the program
can continue where it left off. I basically rediscovered breakpoints.
Using the front panel for debugging doesn't even give you access to see the
Stack Pointer (except during a stack operation) or the CPU registers.
With the source code in the text editor on my laptop (which is also my terminal)
and the symbol table output from the assembler showing the addresses of the
labels, I can easily keep track of where I am in the code and in memory and
what's expected to be in the registers while debugging a program.
I added a couple additional features for a good first pass of the debug
subroutine. I print the flag register as a string instead of just the octal
byte so I don't have to remember which bit is the Zero flag or Carry flag, etc.
I also added the ability to examine the byte at any memory address.
I could add some extras like changing memory or even registers but I haven't
needed to change registers before and the assembler can change memory for me.
And I still have access to the front panel for anything I was doing before.
# Software Debuggers #
If you've used a software debugger, say in an IDE, you might already see what's
left to build a (more or less) fully featured debugger. Instead of adding a
CALL and reassembling, I'd need to go back to my original process of replacing
instructions. By saving the bytes of the assembled program and replacing them
with the CALL to the debug subroutine directly in memory I could dynamically
place breakpoints. And, of course, remembering to replace the original bytes
and fixing up the program counter when continuing execution.
Setting dynamic breakpoints can be a bit tricky, though. You don't want to have
to set it by address, because you don't know the addresses of the code anymore,
and you don't want to put the CALL in the middle of data or an immediate value
or over the address part of another CALL or a JMP.
The debugger would need to be aware of the source code so it can set breakpoints
on a line of executable code. Couple the source with the symbol table (you've
heard of needing debug symbols when using a C debugger?) and you have a map of
addresses of where certain things are and could do some quick counting to get to
the specific instruction the breakpoint needs to go on.
Dynamically adding breakpoints opens up the possibility for single stepping,
stepping into a subroutine or stepping over a subroutine or other such features.
It can be done by dynamically adding or moving the breakpoint CALL.
When it has access to the symbol table, you can also view variables by name. In
assembly, that would be an EQU, a SET or a labeled address used for data
storage. An extra feature would be to be able to tell the debugger if the
variable is a byte, a word, or even a string so it can display the full multi-
byte value.
Of course, there is a lot of detail to get a reliable debugger that can
understand the source and the symbol table and the assembler has to be written
with that in mind. I haven't done all of that work. I have my laptop with the
source and I can save the symbol table output there as well.
I also make some assumptions here about the program being debugged like that
it's using serial IO, has set up the Stack Pointer and has enough stack room for
the debug subroutine to use, and is not using interrupts. I don't know when I'd
not have serial IO or a stack but at least the interrupts would need to be
worked around.
What I've done for now is leave the debug subroutine in my new monitor located
at an easy to remember address so I can add a CALL to that address from any
program I am writing and reassemble it.
# Debug Subroutine #
Getting the Stack Pointer, Program Counter, and Registers was a fun project. It
took me a couple rewrites to get it to do what I wanted and be reasonably
compact. I'm sure smarter people than I could shave some more bytes off (which
goes for all my assembly, I'm not exactly brilliant at this, I'm just getting
by).
When executing a CALL, what happens is that the Program Counter gets pushed to
the stack and gets set to the address CALLed to where execution continues. So
keep track of that. If we want to print the Program Counter from before the
CALL, it just got pushed to the stack for us at the location the Stack Pointer
was at before the CALL. That's 2 pieces of the information we want to output.
To preserve the CPU registers, we need to PUSH each pair to the stack before
doing anything else. The value of the Stack Pointer continues to decrement by 2
for each PUSH.
DEBUG PUSH PSW ; save registers for resetting
PUSH BC
PUSH DE
PUSH HL
Now everything is safe and we just need to pull it back out to print it, but
also to save it so we can restore the registers back into the CPU, reset the
Stack Pointer, and then RET will bring us back to where the Program Counter was
pointing.
We need to move up the stack without moving the Stack Pointer because we use
CALLs while in the DEBUG subroutine which would overwrite whatever was on the
stack which we are trying to preserve. We need our own Stack Pointer which we
can create by setting HL to 000000Q and then adding the current value of the
Stack Pointer to it. This is the only way I know to get the value of the Stack
Pointer. In C, and probably other languages, the stack is used to pass
variables and is called the stack frame so I'll call my copy of the Stack
Pointer the frame pointer. I think a CALL or a RST (which is just a CALL to a
fixed address) and reading off the stack is the only way to get the Program
Counter.
So now we have a frame pointer and we know it was at Program Counter + 2 bytes
per Register down the stack. So increment the frame pointer back up by that
amount. The value is now what the Stack Pointer was before we CALLed debug.
Print the value of our frame pointer.
LXI HL,000Q ; get SP for frame pointer
DAD SP
LXI DE,012Q ; add 10 to get to top of stack
DAD DE
XCHG ; save frame pointer in DE
;SP
LXI HL,SPSTR ; load string pointer
CALL PRNTSTR ; print string
MOV A,D ; high byte
CALL PRNTOCT ; print octal byte as ascii to terminal
MVI B,' '
CALL PRNTCHR
MOV A,E ; low byte
CALL PRNTOCT
Now we move back down the stack. The next 2 bytes is the Program Counter that
the CALL to debug saved for us and we can print that. Then each of the next 2
bytes are the register pairs. Print as we go.
;PC
LXI HL,PCSTR ; load string pointer
CALL PRNTSTR ; print string
LXI BC,177775Q ; -3
DCX DE
LDAX DE ; get high byte
MOV H,A
DCX DE
LDAX DE ; get low byte
MOV L,A
DAD BC ; subtract 3 because we inserted CALL DEBUG
MOV A,H
CALL PRNTOCT ; print an octal byte as ascii to terminal
MVI B,' '
CALL PRNTCHR
MOV A,L ; L is safe from PRNTOCT
CALL PRNTOCT
;PSW
LXI HL,AREGSTR
CALL PRNTREG
LXI HL,FREGSTR
CALL PRNTSTR
DCX DE
LDAX DE ; get F
CALL PRNTFLG ; special print the flags
;BC
LXI HL,BREGSTR
CALL PRNTREG
LXI HL,CREGSTR
CALL PRNTREG
;DE
LXI HL,DREGSTR
CALL PRNTREG
LXI HL,EREGSTR
CALL PRNTREG
;HL
LXI HL,HREGSTR
CALL PRNTREG
LXI HL,LREGSTR
CALL PRNTREG
Meanwhile. the real Stack Pointer remained at the bottom of the stack below all
the registers and we can CALL and RET all day long without overwriting them.
When we're done debugging, we can POP the registers back into place which will
use the real Stack Pointer and then RET back to where we left off in the program
and it has no idea that we were gone.
;continue
DASK LXI HL,CONTSTR
CALL PRNTSTR
DLOOP2 CALL GETCHR
MOV A,B ; copy to A
CPI 003Q ; ^C
JZ DCONT
CPI 033Q ; esc
JZ RESET
CPI 'R'
JZ DRMEM
CPI 'r'
JZ DRMEM
JMP DLOOP2
DCONT POP HL ; restore registers
POP DE
POP BC
POP PSW
RET ; go back
And here are the helper subroutines, most notably how I print the flag register.
;print reg
; HL = prefix string pointer
PRNTREG
CALL PRNTSTR
DCX DE
LDAX DE ; get reg value
CALL PRNTOCT
RET
;read mem address
; TODO on error, drop back to DEBUG, not monitor
DRMEM LXI HL,ADDRSTR
CALL PRNTSTR
CALL READADDR
MVI B,':'
CALL PRNTCHR
MVI B,' '
CALL PRNTCHR
MOV A,M ; read byte
CALL PRNTOCT
JMP DASK
;print flags
; A: register
; SZ0Ac0P1C
; Sign Zero (0) Aux carry (0) Parity (1) Carry
PRNTFLG LXI HL,FLGSTR ; flag string
MOV C,A ; save flag reg
PFLOOP MOV A,M ; get char
CPI 000Q ; check for \0
RZ
MOV B,A ; save char
MOV A,C ; restore flag reg
RLC ; flag into carry
MOV C,A ; save flag reg
JC PFPRNT
MOV A,B ; restore char
ADI 040Q ; lower case
MOV B,A
PFPRNT CALL PRNTCHR
INX HL
JMP PFLOOP
FLGSTR DB "SZ"
DB 020Q ; needed because the assembler doesn't let us
DB "A" ; input lowercase. :(
DB 020Q ; TODO, use lowercase and SUI in subroutine so
DB "P1C\0"</pre> ; we can use all printable chars here
I left out the strings other than for printing the flags but you can see what
they are from the output when we CALL the debug subroutine.
There are some TODOs in the code and there are things to fix in general. It
assumes a number of things and if you mistype when entering an address to view,
it uses the monitor's error subroutine (as it's borrowing the monitor's GETADDR
subroutine) and will drop you to the monitor resetting the stack and basically
blowing up your debugging.
I've already burned it into a PROM chip so, eh, I'll fix it eventually.