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.