home
***
CD-ROM
|
disk
|
FTP
|
other
***
search
/
Oakland CPM Archive
/
oakcpm.iso
/
cpm
/
asmutl
/
meyertut.ark
/
MEYER08.TXT
< prev
next >
Wrap
Text File
|
1987-12-04
|
17KB
|
425 lines
CP/M Assembly Language
Part VIII: Numerical Output
by Eric Meyer
We already know how to write 8080 code to print or input
ASCII (character) strings. Now we need to learn how to handle
numbers.
For example, you might want to use the generic FILTER
program of Part VII as the basis for a word-counting utility --
but how are you going to get it to print its results?
First a little vocabulary.
A "bit" is a binary digit, either 0 or 1 (on/off).
A "byte" is 8 bits, representing a number from 0 to 255 (FF
hex).
A "nibble" (or nybble) is half a byte, or 4 bits, and
corresponds to one hex digit.
Example: suppose the Accumulator holds the value 121
decimal, or 79 hex. This is:
7 9 hex
0111 1001 binary
(Practice with a good ASCII table or conversion chart will help
you a lot here.)
1. Binary versus BCD
As you can see above, hexadecimal (being a power of 2) is a
relatively easy way to represent the binary values with which the
8080 CPU deals.
Decimal isn't.
If you want to deal with decimal values, there are two
alternatives:
1) you can write code to calculate decimal values from binary
for I/O purposes, as we will do here. Or,
2) you can switch to a different internal format.
"BCD" (Binary Coded Decimal) is a different way of
representing values in the CPU itself.
In our earlier example, the value 79 hex in the Accumulator
naturally meant 121 decimal.
However, you might also interpret it as 79 decimal!
This is BCD: each nibble is not just a hex digit, but a
decimal digit.
Binary: 79 hex = 7*16 + 9 = 121 decimal
BCD: 79 hex = 7*10 + 9 = 79 decimal
Of course, you need to make special arrangements for
arithmetic to work out right, but the 8080 does this. An
instruction "DAA", or Decimal Adjust Accumulator, is provided to
use after each arithmetic operation.
BCD has two advantages over straight binary:
1) it makes decimal I/O as straightforward as hex, and
2) most of all, it makes calculations such as division involving
decimal quantities (like dollars and cents) exact.
But we're not accountants, so we will stick to common binary
arithmetic.
You should just be aware that BCD is there.
2. Multiplication and Division
There are no multiply or divide instructions on the 8080,
but the operations can be performed by other means.
The easiest way to multiply is by repeated addition.
Suppose you want to multiply the value in A by 2.
All you need to do is add it to itself: "ADD A".
Then, if you need to multiply by another quantity, say 10,
you can combine additions in ingenious ways:
MOV C,A ;copy original value into C
ADD A ;double it
ADD A ; twice (*4)
ADD A ; 3 times (*8)
ADD C ;add original (*9)
ADD C ; twice (*10, voila)
(If it bothers you that multiplying an integer by 10 takes some
thought, 8080 is the wrong language for you!)
If you're worried about overflow (the value eventually
exceeding 255) you need to be testing the Carry flag along the
way.
Similar tricks can be performed with 2-byte values using the
HL register and DAD H.
This kind of shortcut doesn't work with division, which will
require learning about rotations.
Our new instruction is "RAR", which Rotates the Accumulator
Right one bit (binary digit). The lowest bit moves off into the
Carry flag, and the old Carry moves into the high bit place.
That is, labeling the bits 0-7,
Before: A = 7 6 5 4 3 2 1 0 Carry = C
After: A = C 7 6 5 4 3 2 1 Carry = 0
In numerical terms, this divides the value in A by 2. If
there was any remainder (A wasn't even), it is held in the Carry
flag. Here's a concrete example:
Before: A = 01100100 (100 dec, 64 hex) Carry = 0
After: A = 00110010 (50 dec, 32 hex) Carry = 0
For going the other way, there's also an RAL (Rotate Left)
instruction that works just the same, but ADD A is easier to use.
Be careful of the effects of the Carry bit when using
rotations -- the safe thing to do is to mask your result with an
ANI operation afterward, to ensure that the "new" bits introduced
are all 0s.
One rotation divides by two; two, by four; four, by eight;
and so on. Four rotations in a row divide by 16, bringing the
high nibble into the place of the low one, all you need for
hexadecimal I/O.
Division of 2-byte values has to be done one byte at a time
in the Accumulator using RAR.
Of course, all this is just multiplying or dividing a
register by a constant!
If you need to work with two register values, you'll have to
decrement one while manipulating the other as above (it gets
complicated).
3. Hexadecimal output
Handling numbers in hex is easiest because the 8080 CPU is
designed around binary arithmetic: decimal requires translation.
It's not a bad idea to begin with some simple routines to
handle hex numbers.
The following set of routines allow you to print out either
one- or two-byte hex values:
;subroutine to print out one hex word from HL
;register
HEXWRD: PUSH H ;save original value
MOV A,H ;get high (first) byte
CALL HEXBYT ;show it
POP H ;recover original value
MOV A,L ;get low (second) byte,
;fall thru
;subroutine to print out one hex byte from A
;register
HEXBYT: PUSH PSW ;save the value in A
RAR
RAR ;rotate right four times
RAR ;(brings high nibble to
RAR ; right)
CALL HEXNIB ;display high nibble
POP PSW ;get original low
;nibble, fall thru
;subroutine to print out the low hex nibble from
;A register
HEXNIB: ANI 0FH ;mask off anything in
;high nibble
MVI C,30H ;set up amount to add
CPI 0AH ;is it less than 10?
JC HEXNB1 ;if so OK, go ahead
MVI C,37H ;if not, compensate for
;digits A-F
HEXNB1: ADD C ;add offset, result is
;chr '0'..'F'
MOV E,A ;put it to the screen
MVI C,2 ;with BDOS 2
JMP BDOS
The basic subroutine HEXNIB is based on the fact that the
ASCII codes for the digits '0' . . '9' are 30 . . 39 (hex), so
all you have to do is add 30 hex to turn a hex nibble 0 . . 9
into the corresponding digit (character).
Hex digits 'A' . . 'F' are a little more work because
they're not consecutive with '0' . . '9' in the ASCII table.
There are seven other characters in between to skip over.
Notice how each level builds on the one below (HEXWRD on
HEXBYT, etc), and how each subroutine simply falls through on the
end instead of doing a separate CALL HEX . . ., RET.
(Remember that such equates as BDOS and routines as SPMSG
can be found in earlier parts of this series.)
4. Subroutine style and the stack
If you're starting to nest subroutines deeply (HEXWRD calls
HEXBYT calls HEXNIB calls BDOS . . .) you need to worry about
running out of stack space.
Ideally every subroutine would PUSH most or all registers on
entry and POP them on exit, to preserve their contents; and each
would use CALLs ad infinitum.
In practice this can cause stack overflow, if not properly
managed. This is why the routines above are written as they are:
each preserves only the registers it needs itself.
And each ends by either falling through or JMPing someplace,
for example
JMP BDOS instead of CALL BDOS
RET
Not only is this shorter, it also uses one less PUSH onto
the stack. (Convince yourself that these really are equivalent,
referring to our earlier discussion of the stack. Why remember to
return, when all that's there is a RET?)
Of course you can avoid stack overflow by setting up a stack
of your own as big as you like; but that's a complication we
haven't ventured into yet.
For now, we use the default stack handed to us by CP/M, and
we'd better not count on it being more than about 16 PUSHes deep.
5. Displaying free memory
Here is a simple question you can now answer: how much
memory (of the total 64K in your system) is free for user
programs?
This routine will tell you:
FREMEM: CALL SPMSG ;announce what we're
;doing
DB 'Bytes free: ',0
LHLD BDOS+1 ;get the BDOS address
;into HL
MVI L,0 ;round down to even page
DCR H ;subtract 100H at bottom
JMP HEXWRD ;say it
The trick is that the BDOS address located at BDOS+1 (0006H)
points close to the beginning of the BDOS in high memory.
For example, if the BDOS address is DF06, then memory from
DF00 up is filled by the BDOS, but the rest (except from 0000 to
0100) is free. You will then see a message like
Bytes free: DE00
Admittedly, this is a bit cryptic; you might want an answer
more like "55K", but for that we need decimal output.
6. Decimal output
Of course, most of the time you will want to see results in
decimal form.
There is no direct way to convert binary (base 2) to decimal
(base 10); you just have to calculate each decimal digit one at a
time, by subtracting powers of 10!
Here is a basic routine DECOUT that can print a two-byte
value in decimal form.
For each decimal digit, the routine has to see how many
times 10^n can be subtracted from the value given.
All digits print; for example 63 shows as "00063".
;subroutine to print decimal value 0-65535 from
;HL register
DECOUT: LXI D,10000 ;figure each of 5 digits
CALL DECSUB
LXI D,1000
CALL DECSUB
LXI D,100
CALL DECSUB
LXI D,10
CALL DECSUB
LXI D,1 ;just fall thru for the
;last
DECSUB: MVI C,0 ;initialize count
DSLOOP: MOV A,H ;get high byte of value
CMP D ;compare to 10^n
JC DECL ;if less go here
JNZ DECG ;if greater go here
MOV A,L ;same, have to look at
;low byte too
CMP E
JC DECL
DECG: INR C ;greater, increment
;count
MOV A,L
SUB E ;and subtract 10^n
MOV L,A
MOV A,H ;(D,E is subtracted
;from H,L)
SBB D ;note the Borrow
;(carry) here
MOV H,A
JMP DSLOOP
DECL: PUSH H ;less, count is finished
MVI A,30H
ADD C ;convert to ASCII
;digit 0..9
MOV E,A
MVI C,2
CALL BDOS ;show it
POP H ;restore remaining value
RET
A well written program is easy to alter according to the
task at hand. As an exercise, you should be able to modify this
subroutine to:
(1) print spaces instead of lead zeros (" 63")
(2) ignore lead zeros entirely ("63")
(3) print 3 digits only (quantities from 0 to 999)
(4) print out in Octal (base 8) instead of Decimal and about
anything else you want.
Now we can rewrite our little free memory program above to
give a more intelligible result.
Just substitute DECOUT for HEXWRD, and you'll see something
like
Bytes free: 56832
or about 55K. (Remember that 1K is 1024 (400H) bytes.)
If you want to see the result print out as "55K", try
dividing the value in HL by 1024 (that's by 2, ten times) before
printing it.
7. The WORDCNT program
And now what you've all been waiting for: a word counting
program simply reads through a file and counts words as they go
by.
I have begun with our old FILTER.ASM, removed all the output
file code, and changed the "FILTER" subroutine so that it tries
to count words.
;*** WORDCNT.ASM word count program
;
BDOS EQU 0005H ;basic equates
FCB1 EQU 005CH
;
ORG 0100H ;programs start here
;
START: LXI D,FCB1 ;point to 1st FCB (source
;file)
CALL GCOPEN ;open it for reading
JC IOERR ;complain if error
;
LOOP: CALL FGETCH ;get a character
JC IOERR ;complain if error
CPI 1AH ;EOF?
JZ DONE ;quit if at end of file
CALL FILTER ;process it in some way
JMP LOOP ;keep going
;
DONE: CALL SPMSG ;give result
DB 'Words: ',0
LHLD COUNT
CALL DECOUT
EXIT: RET ;all finished
;
IOERR: CALL SPMSG ;error? say so
DB 'IO ERROR',0
JMP EXIT ;and quit
;
;HERE IS THE CHARACTER PROCESSING ROUTINE
;
FILTER: CPI ' ' ;is it a space?
JNZ FILT1 ;if not, do nothing
LXI H,LSTCHR ;YES, check last chr
CMP M ;was also a space?
JZ FILT1 ;if so, do nothing
LHLD COUNT ;if not, END OF WORD
INX H ;increment count
SHLD COUNT ;and save it again
FILT1: STA LSTCHR ;save char for reference
RET
LSTCHR: DB 0 ;1 byte to save last char
COUNT: DW 0 ;2 byte count starts at
;zero
;
;Now add DECOUT from above, and GCOPEN, FGETCH,
;SPMSG from our original FILTER.ASM
When I first wrote a word count program, I discovered that I
had to decide what a "word" was!
You will discover that this is not trivial.
The "FILTER" routine shown above thinks that a "word" is any
series of nonspaces followed by a space. (When it sees a space
after a nonspace, it counts a word.)
That's not bad; but what about tabs? Carriage returns?
Hyphens? End of file?
How many "words" does the following paragraph contain:
At exactly 8:00 - not a min-
ute late -- he collected his mother-
in-law et cetera at the airport.
(I think the answer is 16; you might disagree. Whatever you
decide, you'll have some work to do on FILTER before it agrees
with you!)
8. The Future. . . ?
We've now successfully covered what I consider the basics of
assembly language programming.
There are many directions to go from here:
* The rest of the 8080 instruction set
* The additional features of the Z80; and
* Almost limitless information about the CP/M operating system's
use of memory, disk files, and i/o devices that can easily
be exploited by the assembly programmer.
Your next step (aside from a book or two, if only as a
complete language reference) should be to pick up the source code
to your favorite public domain utility program (XDIR, SD, CRCK,
BYE, MODEM7, etc) and start learning.
Assembly language has a big drawback (aside from being hard
to write): it is limited to a given family of CPU hardware.
Assembler code for one chip can't be easily transported to
another (except for relatives like the 8080 and 8086/8), while
well written code in high level languages like C or Pascal can be
used almost without modification.
The advantage of assembler is efficiency: it produces the
smallest, fastest code.
Of course this isn't always very important; and it becomes
less so, as faster processors and bigger memory become available.
Many of the tasks we've used as examples here, such as
filtering and word counting, could have been done far more easily
in a higher level language like C, Pascal, or BASIC, with a very
usable result.
But there are times when this isn't true: a good modem
program has to be written in assembler.
Complex graphics, full screen editing, and database sorting
can put you to sleep if the programs aren't written in assembler.
Understanding and modifying these programs, and of course
the CP/M operating system itself, require work in the language
they were written in: assembler.
Today ever fewer programmers work in assembly language.
None the less, or perhaps all the more, you will find it
useful to be literate in it.