(Updated February 18, 2025)
Table of Contents
Overview
In previous chapters, we’ve glossed over details on numbers and mathematics. This is simply because math on this CPU is hard. Remember that all we can do is add. Even subtraction is nothing more than changing signs and adding.
Now, we will discuss the finer details of more significant numbers, bigger math, and how we can print numbers, which have notably gone unmentioned until now.
Printing Numbers – Part I
So, let’s start with basic printing! There are many algorithms for printing numbers. One such algorithm involves alternating modulus and division, thereby retrieving the digits in reverse order. Thrown on a stack, it’s a simple matter to pop them off and print them in succession, thereby giving you the number as an ASCII string.
However, we don’t yet have the luxury of division. We do, however, have the next best thing: subtraction. We can setup a table that starts at the largest multiple of ten and that becomes the starting point for printing the number.
But now we’re getting a little ahead of ourselves. For this initial printing discussion, we will focus on the code below.
; This is the start of the 8-bit library.
define CHROUT $ffd2
define LZF $30
; This is test code for the 8-bit printing
LDX #0
LDY #0
NUMLOOP:
LDA NUMBERS,Y
STA $31 ; LSB
PHA ; SAVE
JSR S8OUT ; PRINT AS SIGNED
LDA #7 ; '\t'
JSR CHROUT
PLA ; RESTORE
STA $31
JSR U8OUT ; PRINT AS UNSIGNED
LDA #13 ; '\n'
JSR CHROUT
INY
INX
CPX #5
BNE NUMLOOP
BRK
NUMBERS:
dcb -128, 127, -1, 1, 0
; S8OUT - print signed 8bit value in $31
; U8OUT - print unsigned 8bit value in $31
; Based on code by Leo Scanlon in
; 6502 Software Design, 1980
; $31 LSB (clobbered)
; $30 LEADING ZERO FLAG (LZF, clobbered)
; CLOBBERS A, PRESERVES X & Y
S8OUT:
JSR S8OUTNEG
U8OUT:
; SAVE X & Y
TXA
PHA
TYA
PHA
; begin
LDY #0 ; INIT TABLE POINTER
STY LZF ; CLEAR LZF
S8NEXTDIG:
LDX #0
S8DOSUBTRACT:
LDA $31
SEC
SBC S8SUBTABLE,Y
BCC S8ADDBACK
STA $31
INX
JMP S8DOSUBTRACT
S8ADDBACK:
; there's no actual add back for 8-bit
TXA
BNE S8SETLZF
BIT LZF
BMI S8CNV2ASCII
BPL S8MOVUPTBL
S8SETLZF:
LDX #$80
STX LZF
S8CNV2ASCII:
ORA #$30
JSR CHROUT
S8MOVUPTBL:
INY
CPY #$02 ; END OF TABLE?
BCC S8NEXTDIG
LDA $31 ; LAST DIGIT
ORA #$30
JSR CHROUT
; RESTORE Y & X
PLA
TAY
PLA
TAX
RTS
; END S8OUT
; This routine inverts 8-bit sign and
; prints a '-'. Clobbers A.
S8OUTNEG:
BIT $31
BPL S8OUTEND
JSR S8INVSGN
LDA #$2D ; '-'
JSR CHROUT
S8OUTEND:
RTS
; END S8OUTNEG
; Inverts sign of $31
; Clobbers A.
S8INVSGN:
LDA $31 ; LOAD LSB
EOR #$FF ; FLIP BITS
CLC
ADC #1 ; ADD ONE
STA $31 ; STORE
RTS ; DONE
S8SUBTABLE:
dcb 100
dcb 10
Entry Point
That’s a lot to digest all at once, but we’ll discuss most of what’s happening. Let’s begin by discussing the dual entry point for this code.
S8OUT:
JSR S8OUTNEG
U8OUT:
; SAVE X & Y
We’ve stacked the labels on how to start this subroutine. Its purpose is to print out an 8-bit number, which may be signed. Therefore, we have two entry points: S8OUT
if you want to print a signed number and U8OUT
to print an unsigned one. The only difference is that the signed version first calls S8OUTNEG
, which calls S8INVSGN
to invert the sign and then print out a minus sign.
Inverting the Sign
So, let’s now talk about inverting the sign.
; Inverts sign of $31
; Clobbers A.
S8INVSGN:
LDA $31 ; LOAD LSB
EOR #$FF ; FLIP BITS
CLC
ADC #1 ; ADD ONE
STA $31 ; STORE
RTS ; DONE
The expectation is that the number to be printed is first moved to the zero-page address of $31
. We proceed to flip the bits using exclusive or, then, add 1. That’s it. The sign is now inverted.
Remember that this is only necessary if we treat the number as a signed value. Inverting the sign makes it easier to get the digits for the negative number. Consider the following:
255 is 11111111 as an unsigned number -1 is 11111111 as a signed number
It’s important to notice what’s happening here. When applied to our algorithm, the same bit pattern will give vastly differing results. This is why we have two entry points. If you treat the value as a signed quantity, call S8OUT
; otherwise, call U8OUT
.
Testing for Negative
Incidentally, it may be interesting to note how S8OUTNEG
works.
; This routine inverts 8-bit sign and
; prints a '-'. Clobbers A.
S8OUTNEG:
BIT $31
BPL S8OUTEND
JSR S8INVSGN
LDA #$2D ; '-'
JSR CHROUT
S8OUTEND:
RTS
; END S8OUTNEG
Note the use of BIT
. This instruction does several things at once, but we’re only interested in copying bit 7 of the operand into the N
flag. If the result of N
is set, the number tested is negative. This means we need to invert the sign and print a minus sign. The BPL
reacts to a positive number and jumps to the end of the subroutine.
Subtraction Table
Now for the magic of how this subroutine works. As you’ve likely guessed, it’s not magic but a series of subtractions starting with the largest possible and working our way down the powers of ten. Take a look at the following.
S8SUBTABLE:
dcb 100
dcb 10
This is a data byte table with 100 and 10 in descending order. Since the range of unsigned numbers is 0 to 255 and the range of signed is -128 to 127, we can see that we never get above the hundreds. Therefore, we don’t need anything more significant in our table.
Let’s go over the details. Remember that the number to be printed must first be moved into the zero page address $31
.
First, there is some housekeeping. We’ve seen the beginning of this subroutine earlier, but revealing a bit more, we see some values placed on the stack. We take X and Y each and move them to the accumulator, then push them onto the stack. This allows us to preserve their values. Otherwise, we’d clobber them, and the calling subroutine would have to take additional action. It’s a small price for us to provide peace of mind that using this subroutine is safe and only the accumulator is affected.
S8OUT:
JSR S8OUTNEG
U8OUT:
; SAVE X & Y
TXA
PHA
TYA
PHA
; begin
LDY #0 ; INIT TABLE POINTER
STY LZF ; CLEAR LZF
This is also reversed at the end of the subroutine. We pull the values off the stack and restore Y
and X
to their previous states just before we return.
; RESTORE Y & X
PLA
TAY
PLA
TAX
RTS
; END S8OUT
Now, let’s dig into how the table works. The Y
register will index the table, while the X
register will keep track of the number of times we’ve subtracted. Of course, this value can never exceed 9.
There is also one other handy memory location. Zero page address $30
is our LZF
or Leading Zero Flag. Since it’s possible we won’t use the hundreds or even the tens, we want to ensure that 7 isn’t printed as 007. When LZF
is set, there are no more leading zeros.
; begin
LDY #0 ; INIT TABLE POINTER
STY LZF ; CLEAR LZF
S8NEXTDIG:
LDX #0
S8DOSUBTRACT:
LDA $31
SEC
SBC S8SUBTABLE,Y
BCC S8ADDBACK
STA $31
INX
JMP S8DOSUBTRACT
We subtract the current table value. If the carry clears, we’ve borrowed, so we’re done counting the hundreds or tens, or whatever we’re on.
Otherwise, we keep subtracting, incrementing X
along the way. Here’s what happens when we’re done subtracting.
S8ADDBACK:
; there's no actual add back for 8-bit
TXA
BNE S8SETLZF
BIT LZF
BMI S8CNV2ASCII
BPL S8MOVUPTBL
The nature of the S8ADDBACK is taken from the more significant numbers (there’s a chapter with 16- and 32-bit examples.) We kept the naming the same. We move X
to the accumulator and test it. A lot is happening in that small bit of code.
- If A’s not zero, set LZF.
- If A’s zero and LZF is set, print the digit.
- Else, move up the table to the smaller tens.
This brings us to the following code. Setting the high bit of LZF indicates that we’ve seen our first non-zero digit.
S8SETLZF:
LDX #$80
STX LZF
S8CNV2ASCII:
ORA #$30
JSR CHROUT
S8MOVUPTBL:
INY
CPY #$02 ; END OF TABLE?
BCC S8NEXTDIG
LDA $31 ; LAST DIGIT
ORA #$30
JSR CHROUT
Converting to ASCII is easy. Or-ing with #$30
is the same as adding #$30
to the number to make it a printable digit. If this is unclear, please consult an ASCII table with hexadecimal as one of the column options.
Finally, moving up the table involves increasing Y
and checking to see if the table’s been exhausted. If it has, we’ve reached the last digit, so we convert to ASCII and print it.
The complete number has now been printed.
Printing the Number
Interestingly enough, we’ve already printed it! The previous mini-sections define how the pieces fit together to produce a printed number. However, we can still discuss how we tested the algorithm.
First, direct your attention to the fact that we’re using Y
to control the testing of the S8OUT
and U8OUT
subroutines. Hopefully, you can now appreciate why the subroutine takes the time to preserve the registers and make it easier for programmers to use them.
; This is test code for the 8-bit printing
LDY #0
NUMLOOP:
LDA NUMBERS,Y
STA $31 ; LSB
PHA ; SAVE
JSR S8OUT ; PRINT AS SIGNED
LDA #7 ; '\t'
JSR CHROUT
PLA ; RESTORE
STA $31
JSR U8OUT ; PRINT AS UNSIGNED
LDA #13 ; '\n'
JSR CHROUT
INY
CPY #5
BNE NUMLOOP
BRK
NUMBERS:
dcb -128, 127, -1, 1, 0
The label NUMLOOP
marks the top of the loop controlled by Y
. We use absolute addressing (indexed by Y
) to load a number into the accumulator. This is stored in $31
and the stack.
We then print the signed version of that number, pull the copy from the stack, and store it back into $31
to print as an unsigned value.
We continue incrementing Y
until all the numbers have been printed.
Bigger Numbers
As this section suggests, we need more significant numbers. Using 8 bits just won’t do. So, we need to consider 16- and 32-bit values. Anything more substantial on this CPU is unrealistic.
We will be storing the values as little-endian. We will use the numbers $89, $89AB, $89ABCDEF to demonstrate how this will work.
8-bit $89 is 10001001, which is -119 signed and 137 unsigned This is stored in memory as $89. 16-bit $89AB is 1000100110101011, which is -30293 signed and 35243 unsigned. This is stored in memory as $AB, $89. 32-bit $89ABCDEF is 10001001101010111100110111101111, which is -1985229329 signed and 2309737967 unsigned. This is stored in memory as $EF, $CD, $AB, $89.
Given the nature of this CPU, little-endian is incredibly smart. Adding two numbers together allows us to start at low memory and have the carry extend into the next byte.
Let’s see what this looks like.
Bigger Math
When we discussed printing 8-bit numbers, we also mentioned needing a facility for more significant numbers. One of the appendices contains code for printing 32-bit numbers. However, that code is beyond the scope of this topic, so for now, we will refer to it as S32OUT
. It prints the little-endian value stored in $31
through $34
.
Consider the following piece of code.
define CHROUT $FFD2
ldx #0
clc
addloop:
lda num1,x
adc num2,x
sta $31,x
cpx #3
beq adddone
inx
jmp addloop
adddone:
jsr S32OUT
brk
num1:
dcq -238729586 ; $8e, $46, $c5, $f1
num2:
dcq 1279846486 ; $56, $e8, $48, $4c
Like adding numbers on paper, we start at the least significant end, work toward the most significant, and carry as we go.
We are doing the same thing here but with a loop. Since these are stored as little-endian, the little end is stored first. Using X
, we can treat num1
and num2
as arrays of bytes.
Treating the range $31-$34
the same way (and setting it up for printing), we simply read a byte from num1
, add the byte from num2
, and store the result in page zero. After we iterate over all 4 bytes, we have the complete sum with carries. We then print the number and end.
One frequently asked question is, “If I’m adding two numbers, how do I know that a single-bit carry is enough?”
That’s a great question! Let’s do some mental gymnastics first, and then we’ll examine the bit pattern.
Let’s take this from an unsigned perspective because this explains it best. Remember that 8 bits have the largest possible value of 255. If we add one, we need one more bit to get us to 256 – this is our carry. Effectively, bit 9
Not convinced?
Ok, take 255 and 255 and add them together. It’s 510, correct? So, if bit 9 allows us to break into 256 plus the original 8 bits, we are still shy of bit 10 which is 512.
Still not convinced? Ok.
C Carry -> 1 1111111 11111111 (255) + 11111111 (255) -------- 1 11111110 (510, if you include the set carry)
The individual bit carries are shown. The singular carry bit (C
) for the overall operation is also shown. As you can see, no matter how big we make the numbers for the sum, we never need more than one bit to make this work.
So, to make bigger numbers, we just need one carry bit to carry the extra into the next byte as we add.
Printing Numbers – Part II
This chapter introduced the basic concept of printing numbers. They were small, simple, and fast. Now, with 4 bytes for a 32-bit number, you can imagine that printing more significant numbers involves much more subtraction, which takes longer.
In Appendix A, we have a lot of code examples that show how to accomplish 8-, 16-, and 32-bit printing. Remember that this all ran on a 1.0MHz MOS 6502 CPU. So, it’s going to be a little pokey, especially in an emulator/simulator trying to mimic that clock speed.