CP/M Asssembly Language: Part XI (a) Enhanced Console I/O (b) The Disk Directory by Eric Meyer This month's installment is mostly about the structure of the CP/M disk directory, and how to use it. As an exercise we will construct a utility, XREN, that can rename a group of files using wildcards. But first, I want to return to the subject of console I/O and offer some enhancements to what we have previously covered. 1. Console Status Long ago, we described how to use BDOS 1 and 2 to get console input, and to send a character to the console. However, there are a few refinements of which you should be aware. First, it is useful to be able to tell whether or not a key has been pressed, without actually reading it in if it has. Suppose a program is displaying a real-time clock on screen while waiting for user input. If it does this: CALL CKDISP ;display the clock time MVI C,1 CALL BDOS ;get key input everything (including its clock display) will stop until a key is pressed! The solution is to use BDOS 11 (Console Status) in a loop that only goes to read a character when it knows there's one waiting: LOOP: CALL CKDISP ;display the clock time MVI C,11 ;check console status CALL BDOS ;has a key been pressed? CPI 0 ;if not, loop and JZ LOOP ; redisplay until one has BDOS 11 returns zero in A if there is no key waiting to be read from the keyboard, and nonzero if there is one. Similar tricks are used by text editors that mustn't take the time to redisplay the screen while you're still typing in text, games that want to keep the enemy closing in on you while you're still deciding which direction to fire in, and so on. 2. Direct Console I/O BDOS 1 and 2 are not "direct" I/O functions: they both do some extra processing, which you may sometimes not want. BDOS 1 echos the character it gets to the console; BDOS 2 does some interpreting of the outgoing character (e.g., expanding tabs to spaces). The BDOS 6 function provides "direct" console I/O, for times when you want to read in a character without echo, or to send out a character without translation. For output, you place the character in the E register; for input, you put 0FFh there. Thus the sequence: MVI E,char MVI C,6 ;direct console I/O CALL BDOS will send "char" directly to the console. The input function is kind of a combination of BDOS 1 and 11 (input and status): MVI E,0FFH MVI C,6 CALL BDOS will return 0 if no key has been pressed. If there is a key, it will return it without echoing it. So if you want to wait and get a character without echo, you can do this: LOOP: MVI E,0FFH MVI C,6 CALL BDOS CPI 0 ;keep trying until key ready JZ LOOP This is very useful: there are times when you don't want the character typed to show on the screen at all, or at least not until you determine whether it was legal. The above method works under both CP/M 2.2 and 3.0. In addition, CP/M 3.0 adds two more features to BDOS 6: if you MVI E,0FEH, you get a plain console status function (like BDOS 11), and if you MVI E,0FDH, you get plain console input. You may be tempted to use the "FD" option in place of the LOOP above, as it's simpler, but remember that CP/M 2.2 doesn't support this. In fact, if you run either of these options under CP/M 2.2, they will send a funny character (probably "}" or "~") to the console, and not read in anything at all! So it may be best to stick with the "FF" option alone. 3. The Disk Directory Now back to our main topic: the CP/M disk directory. You need to know how this is put together if you want to write programs to display a directory, rename files, etc. You also can use a "disk doctor" program like DU to UNerase files, move them to different user areas, and so on, if you know how a directory is organized. CP/M divides a disk into three parts: * The "system tracks", used to "boot" the CP/M system; * The "directory"; and * The "data area". On an Osborne SSDD (200k) disk, for example, the first 15K is the system tracks, then 2K for the directory, then 183K of data space, allocated in 1K blocks. Each directory entry takes 32 bytes, so 2K will hold 64 entries. The entry looks very much like the File Control Block (FCB) whose structure we've already examined. (This is no coincidence; when you open a file, CP/M uses the FCB as a "working copy" of the directory entry.) FCB: d F I L E N A M E T Y P e x x x x x x x x x x x x x x x x x x x c r r r The actual directory entry structure is: u F I L E N A M E T Y P e x x f g g g g g g g g g g g g g g g g The filename and "e"xtent byte are in the same place -- we'll talk about the "f"illed byte in a minute. The "d"rive byte has turned into a "u"ser byte. This disk may be logged in as any CP/M drive, so this position is used to store the user area of the file. These run from 00 to 0F (15). If you see "E5" in the user byte, the file has been erased, and its allocated groups may now be in use by another program, so better not mess with it, for now. CP/M Plus also uses some other values, like "20" (32), to indicate disk labels, password XFCBs, and time/date stamp areas, all of which occupy directory space. We will ignore these. The "g"roup bytes record where on the disk the file data has been stored. An allocation "group" is a block of space on the disk, excluding the system tracks. On an Osborne SSDD disk, each block is 1k; the directory occupies groups 0-1, and the rest (2 . . .) are used for file storage. When you first copy files onto a disk, the groups will be nicely sequential, e.g., 0 P I P _ _ _ _ _ C O M 0 x x x 02 03 04 05 06 07 00 00 00 00 00 00 00 00 00 00 0 X D I R _ _ _ _ C O M 0 x x x 08 09 00 00 00 00 00 00 00 00 00 00 00 00 00 00 But after you've erased a few files and overwritten them, causing the groups to be reassigned, things will get more complicated. Notice that there's room for 16 groups, or 16K, also called one "extent". (It is possible for a larger disk to have a 2K or 4K block size, in which case each entry holds 32K or 64K, two or four extents.) What happens when a file grows larger than this? It has to continue into another directory entry. 0 H U G E _ _ _ _ F I L 0 x x 80 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 0 H U G E _ _ _ _ F I L 1 x x 17 12 13 14 15 00 00 00 00 00 00 00 00 00 00 00 00 Note how the "e"xtent byte shows that the first entry is extent 0, the second is extent 1. Observe also the "f"illed byte: this tells you how many records in the extent are occupied. If the extent is full, you'll see 80h (128), if not (as is usually the case for the last extent of a file), you'll see something less. One final caveat. You may find that the high bit is set on one or more of the file name characters in the directory. This is where CP/M stores the file "attributes" like SYStem. You will often want to mask this off when manipulating filenames gotten from a directory. 4. Searching for Files You can't read or write the directory tracks directly using BDOS calls. All you can do is search for particular filenames using the BDOS functions 17 (Search First) and 18 (Search Next). Suppose you want to know whether HUGE.FIL is in the directory of disk A:. You would set up an FCB for A:HUGE.FIL: FCB: 1 H U G E _ _ _ _ F I L 0 x x x x x x x x x x x x x x x x x x x 0 x x x Now you could try to open the file; an error would indicate that it didn't exist. But let's use a more sophisticated method: LXI D,FCB MVI C,17 ;Search First CALL BDOS Now we get a whole wealth of information! If the file didn't exist, the Accumulator will contain 0FFh. But if it did, A will contain a value from 0-3, and the current DMA (0080h, if you haven't changed it) will contain the record from the directory that contains this entry (and three others). The code 0-3 tells you which one of these is the one you wanted: multiply by 32, then add the DMA address, and you will be pointing to it. Here's the whole sequence (assuming DMA=0080): LXI D,FCB MVI C,17 ;Search First CALL BDOS CPI 0FFH JZ NOFILE ;quit if not found ADD A ADD A ADD A ADD A ADD A ;code * 32 MOV E,A D,0 ;put it in DE LXI H,0080H DAD D ;add DMA: pointer to entry At this point, HL points to the start of the desired directory entry. You could now do all sorts of things: examine the attribute bits to see whether the file is SYStem or R/O; try to figure out how big the file is . . . But this is most commonly used in conjunction with the Search Next command, because you can find any number of specific files that match some ambiguous (general) pattern. The character "?" in an FCB serves as a "wildcard" to match any letter of a filename. When you type a "*" in a filename, CP/M fills out all the remaining places with "?". So for example, if you type: A>DIR B:*.COM CP/M parses this into the default FCB (at 005Ch) as follows: 005C: 2 ? ? ? ? ? ? ? ? C O M 0 x x x x x x x x x x x x x x x x x x x 0 x x x It will then use BDOS 17 and 18 to find, one at a time, every file that matches that description. After BDOS 17 is used once to establish that some files exist, BDOS 18 can be used repeatedly to find all the rest that match. This is how CP/M's "DIR" function works. It's important to note that most BDOS file functions do not work with "ambiguous" filenames (wildcards). The only ones that do work are these two Search functions, and Delete File. (Be careful with that one!) As an exercise, you might consider writing your own little "XDIR" program. Use the code above (remember to substitute BDOS 18 for 17 after the first find) to find each file and point to the name, then print out the string. Once it works, you can refine it by deciding for yourself how to separate one from another, whether to add a "." between the file name and type, and how to keep track of when to start a new line to keep the display neat. If you get lazy, peek ahead to parts of the XREN program coming up. If you really want a challenge, consider trying to print the names out in alphabetical order. 5. A File by Any Other Name . . . Of course, you already have at least one program like XDIR that does this, and more. So our programming example here will be something more complicated -- an XREN command, that allows renaming groups of files. As you may already know, there is a BDOS function 23, Rename File. To use it, you put the original filename at the start of the FCB, and the new one in the middle, e.g., FCB: d O L D N A M E _ T Y P x x x x d N E W N A M E _ T Y P x x x x x x x x and then you call BDOS 23: LXI D,FCB MVI C,23 ;Rename File CALL BDOS This should return 0 if successful. You will get an error if the old name didn't exist, or the new one already does, or the file is read/only, and so forth. (A word about these errors: you want to avoid them wherever possible, because they will often cause an immediate BDOS error message and warm boot. CP/M is rather uncivil about these things.) There are times when you would like to rename files en masse; e.g., A>REN b:*.COM=B:*.OBJ really ought to be able to rename all the OBJ files on B: to COM. Unfortunately this won't work: the simple CP/M REN command (and BDOS 23, which it uses) only work with "unambiguous" names. Of course, now we know enough to write our own program to successively find each specific filename that matches, and rename them one at a time with BDOS 23. We'll call it XREN, and its syntax will be nearly the same as REN: A>xren newname oldname except that now the names can contain wildcards. (Note that no "=" is used; more on this below.) But before the listing, a few preliminary remarks. First, one thing about "Search Next". It has to be used more or less consecutively, i.e., most disk operations will disturb the search sequence if used. This includes trying to open files, or starting another search. (XREN, for example, is going to have to check each time whether a file by the new name already exists, and ask whether you want to overwrite it.) For this reason, the best technique is to get all the names at once, saving them in a list to be processed afterwards. Mass renaming presents some problems that aren't apparent at first glance. One is the fact just alluded to, that (some) of the destination file names may already exist. Besides this, unless both source and destination names have wildcards in exactly the same places, it can be challenging to decide exactly what you meant to do! For example, consider: A>xren mail*.* letter*.* When this comes upon the file LETTER06.BAK, what should it call it? You might expect MAIL06.BAK, but can you see that this requires some guesswork, and moving characters from one position to another in the filename? You can try to implement this if you want, but for now I will stick with a simpler alternative: a one-to-one correspondence between letters. So XREN in this case will rename LETTER06.BAK to MAILER06.BAK. Similarly, what are we to do when there are more wildcards in the source, for example: A>xren letter*.* mail*.* Again, you might want this to rename MAIL06.BAK to LETTER06.BAK, but this is not straightforward. What would happen to MAIL1234? (LETTER1234 won't fit.) Would you want it to produce LETTER12? LETTER34? Again I will opt for a simple correspondence, so that in this case MAIL1234 will be renamed to LETTER34. (Note a potential problem: XREN also would try to rename MAIL6534, etc., to the same thing!) I will leave you the option of playing it really safe, and aborting with an error if the source has more wildcards than the destination. See the optional bit of code in the VERIFY routine below. I've gone into all this detail so you will understand why the simple CCP "REN" command can't handle mass renaming. Since the whole process can be so confusing, we will have XREN list out each file renamed at the console, like this: A:MAIL01 .BAK = A:LETR01 .BAK If the destination already exists, we will ask "Overwrite?" and get a "Y"es/"N"o answer. And in any case we will keep an eye on the keyboard so you can abort at any time with ^C if you don't like what you see. (Note how our new BDOS 6 call is ideal for this.) One final remark: if you have CP/M 3.0, the "transient" portion of the REN command (RENAME.COM) is in fact capable of mass renaming, and you may not want to go to the trouble of generating XREN for yourself. But you may still find it interesting to see how it's done. 6. Mass Renaming: XREN Here, then, is the complete source code for XREN. Most of it should seem familiar by now. Comments are still provided, but not as extensively as in the past. ;*** XREN.ASM - Global Rename utility ;*** Version 1.0 - for 8080 CP/M 2.2 ; BDOS EQU 0005H ;Page Zero equates FCB EQU 005CH FCB2 EQU 006CH DMA EQU 0080H ; ORG 0100H ; CALL VERIFY ;ensure legal arguments JC ERROR LXI H,FCB2 ;copy source into SFCB LXI D,SFCB LXI B,16 CALL LDIR SUB A ;initialize count to 0 STA FILES MVI C,17 ;Search First JMP FIND0 ; FIND: MVI C,18 ;Loop to find the files ;(Search Next) FIND0: LXI D,SFCB ; [entry for first time] CALL BDOS CPI 0FFH JZ GOTALL ;Quit if not found LXI H,DMA ;Add 32*A to DMA to CALL A32HL ; point to found file XCHG LXI H,FILES ;get file count MOV A,M INR M ;increment it JZ TOOMNY ;error if overflow past 255 LXI H,FLIST CALL A16HL ;Point to next list entry XCHG LXI B,16 ;Put the name there CALL LDIR JMP FIND ; GOTALL: LDA FILES ;were there any? ORA A JZ NOFMSG ;if not, say so and quit ; RENAME: LDA FILES ;Loop to rename the files DCR A LXI H,FLIST CALL A16HL ;point to last source LXI D,RFCB LDA FCB ;copy drive number from FCB, STAX D ; NOT user from directory INX D INX H LXI B,15 ;copy rest of source entry CALL LDIR LXI H,FCB ;copy in destination mask LXI D,RFCB2 LXI B,16 CALL LDIR CALL NEWNAM ;construct new name from mask CALL SHOWIT ;show "NEWNAME=OLDNAME" LXI H,RFCB2 LXI D,SFCB ;copy new name to SFCB LXI B,16 ; for test CALL LDIR CALL EXTEST ;Free to overwrite ;existing file? JC NORENM ;Skip following if "No" LXI D,RFCB MVI C,23 ;Rename File CALL BDOS ORA A JNZ ERROR NORENM: CALL INKEY ;check keyboard CPI 3 ;abort on ^C JZ ABORT CALL CRLF ;show new line LXI H,FILES DCR M ;count down on FILES JNZ RENAME ; DONE: RET ;all finished, exit quietly ; ; --- messages --- NOFMSG: CALL SPMSG ;source file was not found DB '',0 JMP DONE ERROR: CALL SPMSG ;something wrong w/ arguments DB '',0 JMP DONE TOOMNY: CALL SPMSG ;over 255 files match source DB '',0 JMP DONE ABORT: CALL SPMSG ;you pressed ^C DB '',0 ; JMP DONE ; ; --- subroutines --- VERIFY: MVI C,25 ;Verify argts are legal ;(Carry=bad) CALL BDOS ;get logged drive INR A ;adjust from 0 to 1=A: MOV C,A LXI D,FCB LXI H,FCB2 LDAX D ORA A ;if either argt has no drive, JNZ VERF01 ; (0 = default drive) MOV A,C ; insert the correct drive STAX D VERF01: MOV A,M ORA A JNZ VERF02 MOV M,C VERF02: LDAX D CMP M ;drives must be same JNZ VERFNG ;--- omit or include as desired: --- ; INX H ;"Play it safe" code ; INX D ; MVI B,11 ;VERFLP: MOV A,M ;If "?" in source, ; CPI '?' ; JNZ VERFOK ; LDAX D ; CPI '?' ;must have in dest too ; JNZ VERFNG ;VERFOK: INX H ; INX D ; DCR B ; JNZ VERFLP ;---------------------------------- ORA A ;OK, clear Carry RET VERFNG: STC ;No good, set Carry RET ; NEWNAM: LXI H,RFCB ;Construct new name from MVI B,32 ; dest mask NEWN00: MOV A,M ;First strip parity from both ANI 7FH ; (for matching and display) MOV M,A INX H DCR B JNZ NEWN00 LXI H,RFCB+1 ;Then check mask for "?"s LXI D,RFCB2+1 MVI B,11 NEWNLP: LDAX D ;Got a "?"? CPI '?' JNZ NEWN01 MOV A,M ;yes, replace from old name STAX D NEWN01: INX H ;continue INX D DCR B JNZ NEWNLP RET ; SHOWIT: LXI H,RFCB2 ;Show "NEWNAME=OLDNAME" msg CALL SHOSUB ;do first name CALL SPMSG ; then separator DB ' = ',0 LXI H,RFCB ; then second name SHOSUB: MOV A,M ;Subroutine to show one name ADI 'A'-1 CALL CONOUT ;show drive MVI A,':' CALL CONOUT ; then colon INX H MVI B,8 ; then name CALL SHOSTR MVI A,'.' ; then period CALL CONOUT MVI B,3 ; finally, type SHOSTR: MOV A,M ;Subroutine to show string CALL CONOUT INX H DCR B ;do B characters JNZ SHOSTR RET ; EXTEST: LXI D,SFCB ;Check for existing file ;NEWNAM MVI C,17 ; (Return Carry to avoid ; overwrite) CALL BDOS ;Search First CPI 0FFH RZ ;okay, doesn't exist (Carry clear) LXI H,SFCB LXI D,RFCB ;Hmm, does exist... MVI B,11 EXTLP: LDAX D ;are names same? CMP M JNZ EXTST0 INX H INX D DCR B JNZ EXTLP CALL SPMSG ;yes, don't try to change DB ' (Unchanged)',0 STC ;Carry set RET EXTST0: CALL SPMSG ;Different, ask what to do DB ' Overwrite? ',0 CALL CONIN CPI 3 ;^C JZ ABORT CALL UCASE ;uppercase "y"="Y" CPI 'Y' JZ EXTST1 STC ;said No, don't overwrite RET EXTST1: LXI D,SFCB ;said YES, overwrite MVI C,19 ;Delete File CALL BDOS CPI 0FFH CMC ;set Carry if error RET ; SPMSG: XTHL ;print inline message SUB A ADD M INX H XTHL RZ CALL CONOUT JMP SPMSG ; CRLF: MVI A,0DH ;print a CR, LF CALL CONOUT MVI A,0AH JMP CONOUT ; INKEY: MVI A,0FFH ;get key, if waiting CONOUT: MOV E,A ;show character in A MVI C,6 ;direct console I/O PUSH B PUSH H CALL BDOS ;call BDOS preserving BC,HL POP H POP B RET CONIN: MVI C,1 ;console input JMP BDOS ; UCASE: CPI 'a' ;uppercase char in A RC CPI 'z'+1 RNC ANI 5FH ;if it was "a...z" RET ; A32HL: ADD A ;add 32*A to HL A16HL: ADD A ;add 16*A to HL ADD A ; (used to point to FCBs) ADD A ADD A ;32 (or 16) *A ADD L ; +L MOV L,A RNC INR H ;may carry 1 into H RET ; LDIR: MOV A,M ;Move BC bytes from HL to DE STAX D ; [can replace with Z80 LDIR] INX D INX H DCX B MOV A,B ORA C JNZ LDIR RET ; ;--- uninitialized storage --- ;SFCB: DS 36 ;FCB for source ; RFCB: DS 16 ;dual FCB for renaming RFCB2: DS 20 ; FILES: DS 1 ;count files FLIST EQU $ ;and list them starting here ; END 7. Further Details Briefly, here's how XREN works: the source filespec is copied from FCB2 to SFCB, where it is used with Search First/Next (in the loop at FIND) to build up a list of filenames that match the source. This is kept at FLIST, and a count kept in FILES (note that we actually copy the whole top row, 16 bytes, of the FCB for convenience). Then we come to the second loop, at RENAME, where we go back through the list (in reverse order, as it happens) to rename each file. The new filename is constructed by combining the old one and the ambiguous destination, as discussed above. If a file by this name already exists, we ask whether you want to overwrite it. (If the new name is the same as the old, we pass on to the next, to avoid destroying the file.) A few points are worth noting. Pay attention to the use of flags on return from subroutines. Setting the Carry flag to indicate an error condition (and making sure it's clear otherwise) is a common convention. In the RENAME loop, remember that the directory entries found contain user numbers, not the drive numbers we want in the FCBs, so we have to replace these. In VERIFY, we have to decide whether the source and destination drives are the same. Remember that the first byte of an FCB holds 1=A:, 2=B:, and so forth, but you can also have 0, for "logged drive". So we ask BDOS what the logged drive is (it will return 0 for A:, 1 for B: etc, so we have to increment this to agree with the 1=A: FCB convention), and if either FCB has a 0 we replace it with the actual logged drive. This also ensures that the drive names will display correctly. We use both types of console input here: BDOS 1 for the answer to the "Overwrite?" question, when we want to wait for a key and have it echo; and BDOS 6 to check for ^C from the console, where we don't want to wait or to echo it. All the storage areas are labeled at the end, where they won't add to the size of the COM file. You might wonder whether the FLIST may grow so long that it will overwrite the CCP in high memory, but at 16 bytes per entry, there's room for thousands of filenames. Actually, XREN is going to give up if it encounters more than 255 files, because FILES is a one-byte variable, but you could change it to two bytes easily enough. (Note the test and branch to TOOMNY: if you INcRement a byte and it goes past 255 to zero, the Zero flag is set.) 8. Conclusion You'll notice that we didn't include an "=" in the XREN command line, as we would with REN. In fact, if you try: A>xren newfile=oldfile you'll probably see the message "", regardless. This is because most CP/M 2.2 systems don't parse an argument into the second FCB unless it's separated from the first by a space. (Astute readers will recall that earlier [Part VI] I said that they do . . . my mistake. Interestingly enough, CP/M 3.0 does parse the second argument when an "=" is used; this is just one of many subtle differences.) We've been handling arguments the lazy way up to now, by letting the CCP parse them for us. But there are times when you may need to do this yourself (e.g., if you are writing a program that needs more than two filenames as arguments, or that wants something other than a filename, like a long text string). We may cover the subject of parsing arguments later, but for now, if you want to try to modify XREN to accept the "=", here's what you have to know. In addition to the FCBs, the whole command line as entered is available for examination when a program begins. It's in the DMA at 0080, and it starts with a byte telling you the length of the string, for example: 0080: 0A 202A2E4F424A3D2A2E434F4D [...] (12) * . O B J = * . C O M You could add code to the beginning of XREN to scan this string for an "=", and then process the rest into FCB2 as the second filename. You'll want to experiment with variations on the theme of displaying and renaming files from the disk directory. These are constantly needed tasks, and difficult to accomplish in higher level languages. It's good to be able to write utilities to get them done, with minimal effort, the way you want.