In this 1985 book, as in its predecessor (A Programmer's Notebook, 1983) I was trying to demonstrate a professional's thoughtful, systematic approach to software design for the benefit of the generation of enthusiastic amateur programmers that sprang up with the advent of small, cheap computers.
The book did not do well commercially, for a couple of reasons that are obvious in hindsight. First, my own choices: I used RMAC and its associated LINK and LIB tools. There was nothing wrong with these programs as tools, and the ability to build a library of relocatable object modules is absolutely essential to modular software design. However, the RMAC/LINK/LIB package was an extra-cost purchase from Digital Research, and most of the CP/M programmers who might have bought the book didn't have them and weren't interested in getting them.
I felt (and still feel) it was essential to exploit the Z80's instruction set, but once I'd chosen RMAC I had to use the Intel-like assembler mnemonics and syntax that it supported for the Z80 instructions. That put another barrier in the reader's way. However, relocating assemblers that supported Zilog's more appealing syntax were even less available and more costly than RMAC.
What really doomed the book, however, was that it was ridiculously late to market. By the time it reached print the IBM PC and MS-DOS had completely taken over the world of personal computing. Z80-based CP/M computers were still being sold, but hardly anyone cared. Had I written the same book but for the 8086 and IBM's assembler, the story might have been different.
Twenty-five years later, it seems there are still a few CP/M programmers in the world. For their sakes I am putting the book online. A few typos have been corrected and the figures have been redrawn and are somewhat clearer than the original. Otherwise it is the same. Look nearby for access a ZIP archive with the files distributed with the book.
This is a book of programs, and a book about programming. It contains a variety of software, all written in assembly language for a computer equipped with CP/M and a Z80 processor. The software is useful, but the book is about the process of constructing programs: how they are conceived, designed, and coded.
The book is in two parts. Part I contains complete programs, each worked out from an initial specification, through a design in a high-level pseudo-code, to working assembly language.
These programs are built from a kit of simpler parts: a comprehensive set of subroutines for manipulating integers, strings, and especially files. It brings to assembly language much of the convenience of the standard C runtime library, and it makes writing assembly language utilities a lot easier. Part II describes how that toolkit was designed and how it works.
The software in this book requires the CP/M-80 operating system (either CP/M 2.2 or CP/M Plus). It also requires a Zilog Z80 processor chip. I found that certain Z80 features were absolutely necessary to doing the file-handling modules right. While unavoidable, this is a pity; it means that such excellent dual-processor machines as the CompuPro and the Zenith Z-100, which use an Intel 8085 on their 8-bit side, cannot use these programs.
If you obtain the software in machine-readable form, you can execute the programs of Part I immediately. To modify them, however, you will need the RMAC assembler. To link the toolkit modules with programs of your own you must have the LINK-80 linker and the LIB library manager. RMAC, LINK-80, and LIB are products of Digital Research, Inc. (and trademarks as well). They are distributed free with CP/M Plus and are available separately for CP/M 2.2. There may be other assemblers and linkers that will work, but none have been tested.
Assembly language a bad reputation. Most books on programming tell you to use a simple language like BASIC, a clear one like Pascal, or an efficient one like C. I echo that advice: in many cases you should write programs in a higher-level language. This is especially true if you are new to programming. Until you are thoroughly at home with the fundamentals of programming -- until you know the loop, the subroutine, the array and the file in all their varieties, and have practiced breaking a problem down and composing a program from its pieces -- you should avoid assembly language. Once you have mastered those things, you are free to choose a language to suit the task at hand.
As compared to BASIC, Pascal, or C, assembly language is inherently more tedious and difficult to use. Worse, it is not portable; assembly-language programs can only run in the combination of processor and operating system for which they are written. However, assembly language code is normally much smaller and faster than the code generated by the simple compilers available for CP/M. And assembly language gives you access to all the features of the machine and operating system, including many that aren't available in higher languages. Sometimes speed and access to the hardware is crucial to the success of an application.
Commercial programmers avoid assembly language because its extra difficulty raises the cost of a project, while its lack of portability cuts down the size of the market. Still, I heard Mitch Kapor, president of Lotus Development, say that Lotus 1-2-3^ had to be written in assembly language because a prototype written in C was too big and slow to be saleable. And though Apple's MacIntosh owes some of the speed of its graphics to brilliant algorithms, its designers credit a well-honed assembly language toolbox for most of its speed.
Once you decide to use assembly language, your first concern should be to minimize the inevitable extra cost in effort. That's best done through a disciplined approach to design and coding, and by making full use of a library of subroutines. This book aims to demonstrate both those things.
I started out to do a simple update of an earlier book (A Programmer's Notebook: Utilities for CP/M-80, Reston, 1983; now out of print) to make it work under CP/M Plus. But the longer I looked, the less I liked the older book, especially its library of subroutines which was incomplete and had many inconsistencies.
For this book I began by designing and building a comprehensive toolkit of subroutines. I packaged them as functional modules and gave them macro interfaces that were as simple and as consistent as I could make them. (For example, all of the CP/M file operations, even to the testing and setting of file attributes, can be done with simple macro calls and no use whatever of the CP/M File Control Block.) Only then did I write the programs that would use the subroutines.
Half way through, I heard of a comparable package, called SYSLIB, that was in the public domain. I was careful to avoid seeing SYSLIB or any other subroutine package, so as to avoid even a hint of plagairism.
The work was finished in September 1984, just as the market for computer books in general, and books on CP/M in particular, collapsed like a pricked balloon. The publisher that had contracted for the book had, like much of the software industry, become fixated on the IBM Personal Computer. They decided the book wouldn't sell and gave it back to me.
Fortunately the present publisher realizes that there are more than a million CP/M machines in daily, productive use. The people who own them have been using computers longer than the typical PC owner, and since their abandonment by an industry they have become more knowledgeable and self-reliant. A lot of them, we hope, are getting ready to write their own software. Those are the people this book is for.
D. E. Cortesi
Palo Alto, 8/84 and 9/85
Programming is a craft like cabinetry or needlework. After all, a programmer, like any craftsperson, first draws a plan, then uses tools to shape materials in order to produce a useful object. This chapter is a brief review of the programmer's raw materials: the machine's instruction set and the assembler.
The review is necessary, but there can be no correct length for it. If you know little about assembly language, you'll find it too short and it will drive you out, fuming, to find one of the many good tutorials on the subject. If you know the subject well, the only interest will lie in whatever new light I can cast on old information.
If you're primed for disappointment, then, let's begin.
The heart of any computer is a Central Processing Unit (CPU). This chip has a limited repertoire of simple operations. It reads a stream of bytes from storage and decodes them as operations to perform. A program, when expressed as a sequence of bytes ready for execution by a CPU, is a machine-language program. The final goal of programming is to produce a machine-language program, but it's impractical to write programs in the binary numbers of true machine language.
A simple assembler makes it easier to write a program. An assembler is a program that translates symbols into machine language. It recognizes predefined symbols like "M" or "ADD" as standing for CPU operations or machine registers. It recognizes numbers written in decimal and hexadecimal digits and converts them to binary. It lets us define our own symbolic names for numbers and for locations in storage.
In Figure 1-1 you see a simple program. It's about as trivial as it can be; it writes one ASCII character, the control character BEL, to the console. I call it beep.
; BEEP -- toot the console
ProgramBase equ 0100h ; start of any program
BdosJump equ 0005h ; entry to the BDOS
BdosType equ 2 ; request to type 1 byte
AsciiBEL equ 7 ; the BEL byte
org ProgramBase
mvi c,BdosType
mvi e,AsciiBEL
call BdosJump
ret
end
Figure 1-1. A simple program written for an absolute assembler like asm.
I hope you have composed assembly programs as complicated as beep before now. If so, you know that what you see in Figure 1-1 is the source text of a program, which would be prepared with an editor program and stored as a file on disk. The file would, very likely, be named beep.asm.
The program could be assembled with the CP/M command
A>asm beep
The assembler, a program named asm, is called; it reads the source file and performs the translation from readable symbols to binary machine language. The translated program is called an object program. It is recorded as another file, beep.hex in this case.
That file is still not executable. In fact, although all the symbolic names have been stripped from it and replaced with their binary equivalents, beep.hex is still in a symbolic form. A hex file represents the machine-language program as a series of ASCII characters, one for each hex digit (group of four bits). This hex-file format was originally designed for storing object programs on paper tape, but it still has its uses.
CP/M includes a program that will convert the hex form of an object program to true, binary, machine language. It is called load in CP/M 2.2 and hexcom in CP/M Plus. The command
A>load beep
in CP/M 2.2, or
A>hexcom beep
in CP/M Plus will read the file beep.hex. The assembler's output will be converted to binary and written as a new file, beep.com. This file is an exact copy of the machine language program as it must appear in storage before the CPU can execute it. Once beep.com exists, the operator may give the command
A>beep
In response, CP/M will copy beep.com into storage and apply the CPU to it. The result will be a single "beep" from the terminal.
All assemblies go through these two translation steps. First the assembler converts symbols to binary values and records them in an intermediate file. Then a second program converts the intermediate file into an exact, binary image of the program. The operating system can load this image as a command, and run it.
In a simple assembly, the assembler is asm, the intermediate file is a hex file, and the second program is load (or hexcom). In the rest of this book, however, we'll use a relocating assembler, rmac, and a program linker, link, as the second program.
In order to construct a program of machine instructions, we have to know what instructions are available for use. A CPU's repertoire, the vocabulary of its machine language, is called its instruction set. These instructions, and the machine resources they can manipulate, are the raw materials of assembly language programming.
In this book we will use the instruction set of the Zilog Z80 CPU. However, the assembler we will use was originally designed to handle the simpler instruction set of the Intel 8080 CPU, and that causes some problems.
Intel's 8080 CPU was the first microprocessor to have an instruction set with enough power to be useful for general data processing. Its successor, the very similar 8085, is still a useful machine.
The 8080 offers us a very small set of hardware resources with which to work. The first one is an address space of 65,536 bytes of RAM, or "memory," or (as I prefer to call it) working storage. The CP/M operating system preempts two portions of this. It reserves the first 256 bytes, and its code occupies the highest addresses -- typically the top 8 or 12 kilobytes. The remaining addresses are available for our programs and their data.
A second resource is an 8-bit accumulator called register A. Except for loading and storing data, almost everything that the machine does, it does in register A. Only in register A, for instance, may we perform decimal arithmetic or manipulate the bits of a byte.
A third resource is a group of four 8-bit registers known as B, C, D, and E. These may be used to hold single bytes, or they may be paired as BC and DE to hold 16-bit words. In that guise they can be used as addresses for loading and storing a byte from register A. These registers may be incremented or decremented, but they can't receive the result of any other arithmetic operation.
A fourth resource is another pair of byte registers, H and L. Paired as the HL register, these can be used as a 16-bit accumulator. That is, the unsigned 16-bit word in HL may be added to itself, or the BC or DE word may be added into it. The contents of DE and HL may be swapped in a single instruction (but not the contents of BC and HL).
An important feature of the 8080 is that the storage byte whose address is in HL can be used as if it were another 8-bit register on a par with B, C, D or E. In the assemblers used here, that byte is referred to as register M.
A fifth resource is a pushdown stack of words in working storage. The CPU contains a 16-bit register, the stack pointer, which points to the last byte pushed onto the stack. Any of the register pairs may be pushed on the stack, which grows downward (from larger to smaller addresses). A useful oddity of the instruction set is that the word in HL may be swapped with the top word on the stack in one instruction.
The Intel company specified an assembly syntax for the 8080's instructions. Now, remember that there is no necessary relation between the binary bytes that the CPU reads on one hand, and the syntax rules of the assembler that prepares those bytes on the other. The assembly language is merely some engineer's idea of a concise, readable way to write down instructions.
The syntax Intel chose was simple. We might quarrel with some of the choices for operation code symbols -- the use of mov (move) where most other assemblers use "load" can be confusing, and if they had to use it, why couldn't they have spelt it out in full? -- but it is much too late to quibble.
The CP/M operating system was designed to support the 8080, and asm, the assembler that was part of it, naturally supported the Intel syntax for instructions.
Zilog designed the Z80 CPU as a faster, more powerful replacement for the 8080, and it was a quick success. In order to be a true replacement, the Z80 had to be compatible with the 8080; that is, it had to process the same instructions the same way, so that programs written for the 8080 could run on a Z80 without significant changes. Zilog succeeded in this. The machines differ in certain very subtle points about the way the flags are set after certain operations, but it's a rare 8080 program that won't run on a Z80.
The Z80 provides us with more resources. The most important ones are the IX and IY registers. These two 16-bit registers are used to address storage, just as the HL register can address a byte as "register" M. However, while we can reach only one byte through HL, we can reach any of 128 bytes on either side of the address in IX or IY.
The best use of an index register is to hold the base address of a block of related data. Then any byte of the block is just as accessible as any other, and any of them may be used as an operand in an arithmetic or logical instruction. If you persevere into Part II of this book, you'll find that the file-handling subroutines make extensive use of IX to access a block of information about the present state of some file.
The Z80 added yet more features to those of the 8080. It can test, set to 1, or set to 0 any single bit in any byte register. And while the 8080 can shift and rotate the bits of register A, the Z80 extends these operations to all registers. The Z80 can also subtract, as well as add, unsigned words into register HL. That makes it a lot easier to write subroutines for general binary arithmetic.
The repeating instruction was another Z80 innovation. It has six of them, each amounting to a short subroutine wrapped up in a single operation code. The ldir instruction is the best-known; it allows us to copy a block of storage from one place to another in a single operation. In the discussion of string operations in chapter 14 you will find an analysis of the use of Z80 operations like ldir versus the comparable 8080 loops.
The 8080 gives us a set of conditional jump instructions. The Z80 supplements these with a group of "relative" jumps. While an 8080 jump always specifies the full, 16-bit address of the place to jump to, a relative jump contains only an 8-bit number from -128 through +127. When the jump is taken, this offset is added to the present instruction address.
Relative jumps have three advantages. The first is that they are "position independent"; that is, a block of code that contains only relative jumps may be moved to a different place in storage and still work, whereas if an 8080 jump is moved, the address in it has to be modified. The programs in this book don't move around in storage, so position independence doesn't mean much to us.
The second advantage is that a relative jump is one byte shorter than an 8080 jump. That can be important in some rare applications, but if every jump in this book were relative, the programs wouldn't shrink by as much as ten percent.
The final advantage is an esoteric one. As the Z80 does it, an 8080-style jump always takes the same amount of time (ten clock cycles) whether or not the jump condition is satisfied. A relative jump, however, takes less time (seven cycles) if it is not taken, and more time (twelve cycles) if it is taken. So, in code that is fine-tuned for speed, we might write a relative jump whenever we expect the branch to be taken rarely, and an 8080-style jump when we expect it to be taken more often than not.
One relative jump, the djnz (decrement and jump if not zero), is especially useful. This instruction decrements the contents of register B and performs a jump if the result is nonzero. It is just the ticket for controlling small loops, and we'll use it a lot.
After designing a CPU that was so closely compatible with Intel's, the engineers at Zilog went on to specify an assembly syntax that was utterly different. It may have been that the engineers came from different backgrounds, with the Intel people following a DEC tradition while the Zilog folks worked to an IBM model. Whatever the reason, Zilog's official assembly syntax for the Z80 bears no resemblance to Intel's, even where the machine instructions are identical in binary value and operational effect.
Good things may be said of the Zilog assembly syntax. It is consistent; that is, similar operations have similar syntax. The Intel engineers, for example, seemed to think that there was something fundamentally different between loading register A from the byte addressed by HL, which they coded as
mov a,m
and loading it from the byte addressed by BC, represented as
ldax b
At Zilog, they saw these as the same fundamental operation and encoded them as
ld a,(hl)
ld a,(bc)
respectively. Either scheme works, since in any case we programmers soon learn the instructions by heart and think of them by their effects, not their encodings. But two incompatible traditions were established.
That created a problem for the companies that built computers and software based on the Z80. Those that already had an investment in 8080-style software often chose to ignore the Zilog syntax. Instead, they extended the Intel assembly format to cover the additional registers and operations of the Z80. One such company, now defunct, was Technical Design Labs; you will still sometimes see references to "TDL syntax" or to their "TDL assembler," an assembler that was distributed by several companies.
Companies that started out with the Z80 adopted the Zilog style from the start. Software for Tandy Corporation's TRS-80 line, for example, has always used Zilog syntax.
Digital Research Inc., the publisher of CP/M, chose neither course. It didn't supply a Z80 assembler in any form. Even today, the three 8-bit assemblers from Digital Research will accept only 8080 instructions. However, two of these assemblers, mac and rmac, are macro assemblers. We'll consider macro assembly in the next chapter; for now it is enough to say that one advantage of a macro assembler is that you may design your own instruction set. That's what Digital Research did; they supply a library of Z80 instruction macros as a standard part of the assembler package. You include that library in an assembly; then you may write Z80 instructions in extended-Intel syntax.
This is the form of Z80 instructions that you will find in this book: the form established by the Digital Research macro library. Except for the relative jumps, such instructions are rare in the programs of Part I. The 8080 instructions were adequate for almost everything done there. In the toolkit presented in Part II, however, the Z80 facilities are used a great deal.
If you are not as familiar with the Z80's instructions as you'd like to be, join me in an exercise. When I am confronted with a new CPU, I get a handle on it by cross-tabulating its instructions in different ways, looking for relationships between them. If you'll do that for the Z80 instructions I guarantee you'll uncover a few surprises.
In the first table, document all the possible ways of copying a single byte from a source to a destination. The possible sources and destinations include
Table 1-1 is a start at such a tabulation. Its columns represent sources and its rows, destinations. The single instructions that copy a byte have been filled it. Omitting the entries on the diagonal, how many of the missing squares can you fill in with sequences of instructions, and how short can you make the sequences?
If your patience has held up through that exercise, make a similar table for the instructions that will copy a 16-bit word between
Table 1-2 is a start at this 25-entry table. Note how few one-instruction entries there are, and how many are unique to the Z80. Can you find short sequences to fill in the empty entries? Spending a few hours constructing such tables is one of the best ways I know of to feel at home with the resources of a CPU.
There is one one other resource on which our programs may draw. The CP/M Basic Disk Operating System, or BDOS, is present in the machine along with our program. It can perform a number of services, and will if we invoke it correctly. The beep program of Figure 1-1 calls on the BDOS.
The BDOS is located at some address in high storage; that is, it begins at an address higher than the end of our program. We don't know what that address is. It varies from system to system, and it can vary from one command to the next in a single system, although it normally doesn't. However, there is always a jump instruction at location 5 (address 0005h) which is aimed at the lowest instruction in the BDOS. To call upon the BDOS, we need only place the correct arguments in registers and call location 5.
One BDOS argument is a service number that specifies what we want done. It is always passed in register C. The other argument depends on the service being requested, but if it's a byte, it is passed in register E, and if a word, in DE. In beep, the service is to type a byte at the system console. The second argument is the byte to be typed; it's passed in register E.
In Part I of this book, the programs interact with the BDOS only at second hand, through the subroutines of the toolkit. In Part II we will employ many BDOS services in designing the toolkit itself
We turn now to the tools of the craft of programming. The first is a mental tool: the process of abstraction, a supremely important ability in a programmer. First we create abstractions; then we implement them by exploiting the features of the assembler and by creating subroutines and macros.
At the level of machine language, there is only one kind of data, the binary byte. A byte or a set of bytes can be made to represent anything whatever. If we choose, a byte may stand for eight one-bit flags, or a small integer, or a character. A group of bytes can represent a sentence, or a program, or a floating-point number, or a screen image, or a table of addresses.
Such representations are abstractions. We abstract the concept of a number, a table, a screen image; then we impose that concept on the homogeneous bytes of machine storage. "This block of storage contains a message," we decide, "and this one contains a table of numbers." The hardware takes no notice of such decisions. We implement them by writing instructions that treat the first block as a message and the second as a table -- but not, we hope, vice versa.
We use abstract types in any program. Look at the beep program again (Figure 2-1). Even in this trivial exercise I can identify three abstractions. There is "the set of valid BDOS service request numbers." One of those is 2, the request to display one character on the screen. There is "the set of ASCII control characters." One of those is 7, the BEL character. Finally, there is the BDOS itself, the abstraction of a program that will perform a function given certain parameters. This last is not an abstract data type but rather a functional abstraction, the concept of a particular action.
; BEEP -- toot the console
ProgramBase equ 0100h ; start of any program
BdosJump equ 0005h ; entry to the BDOS
BdosType equ 2 ; request to type 1 byte
AsciiBEL equ 7 ; the BEL byte
org ProgramBase
mvi c,BdosType
mvi e,AsciiBEL
call BdosJump
ret
end
Figure 2-1. A simple program written for an absolute assembler like asm
Abstractions are purely human constructions. We may decide that a set of bytes is an English sentence, but that decision is in no way part of a machine-language program. The CPU will not object if we tell it to multiply two sentences, look for a verb in a floating-point number. The assembler will not complain if we give it a program like this.
msg: db 'Hello, there'
call msg
High-level languages help us avoid mistakes like that. They do it with a variety of mechanisms for isolating one data type from another. Assembly language doesn't; it permits us to mix data types in any way we like, including nonsensical ones. However, it also gives us the means to construct our own type-handling mechanisms. The first of these is the symbolic name. We use symbolic names to make clear the distinction between one kind of data and another.
We don't do it for the computer's benefit. Look again at Figure 2-1. When it is translated to machine language, it is compressed by a factor of 40, from more than 360 bytes to just eight. This compression is the result of stripping out every bit of symbolic information. Names of operations translate to one byte; symbolic constants translate to one or two; and all comments are deleted.
Ninety percent of the beep source file is redundant -- to the CPU. But these things are not in the source file to serve the CPU; they are there to serve human purposes. In Figure 2-2 you can see a version of beep that contains the absolute minimum of symbolic information. In translation it compresses by a factor of only five. Since it is eight times less redundant, it is in some sense a more efficient representation of a computer program. However, it is a total failure as a message between humans.
org 256
mvi c,2
mvi e,7
call 5
ret
Figure 2-2. The beep program stripped of symbols.
Every program source file is a message with three destinations. One destination is the processor, which has no ability to process symbolic information. The second is yourself; you record your decisions about the abstractions being used in the program as comments and symbolic names, for quick reference as you work. The third is a future person, either the programmer that will follow you or yourself at a later time.
Since two of the three recipients of a program are human, and since the other is mindlessly unaware of the program's symbolic content, it follows that the symbolic components of the source text deserve as much attention as the purely mechanical elements.
It also follows that the best assembler is the one with the best support for symbolic information, and that we should exploit that support as thoroughly as we exploit the machine. The CP/M assemblers all allow symbolic names to be as much as 16 characters long and can handle hundreds of names in a single assembly. I've made good use of those features in these programs.
Some things and locations are such constant features of CP/M programs that we might as well have standard names for them, and we might as well find a way to avoid having to type them into every program. The assembler's maclib command gives a way to include a file of equated names.
All the programs in this book use a single macro library, environ.lib. It contains, among other things, about 50 names for common things, notably the standard CP/M storage locations and the ASCII control characters. A separate macro library, services.lib, contains names for the BDOS service requests and a service macro that encapsulates the calling mechanism. If we like, we can rewrite Figure 2-1 using these two libraries and standard names (Figure 2-3).
; BEEP -- toot the console
maclib environ
maclib services
prolog
service BdosType,AsciiBEL
ret
end
Figure 2-3. Program beep written using the toolkit macro libraries.
Neither the CPU nor the assembler have any support for abstract data types, but we programmers use them constantly. Four types are so common and so useful that they crop up in every program. This book features a toolkit of subroutines that deal with these four data types: the character, the integer, the string, and the file.
We use use the ASCII code to relate numbers (which is all that a byte can contain) to letters. Once we've decided that a certain byte will contain a character, we tend to talk as if we really could store a letter in it. We can't, of course; even when we write a sequence like this
mvi a,'x'
sta LetterByte
what we have accomplished is to store the number 120 in the byte -- a quantity that has no necessary relationship to the letter x whatsoever. That's the virtue of abstractions. Having decided on the treatment of LetterByte and having accepted the ASCII code, we can write a program in terms of these abstractions. It relieves our mental burdens considerably, as anyone will attest who has entered a program, in binary, to a primitive development system through its toggle switches.
Sometimes we have to switch between one abstraction and another on the fly. For instance, if we want to make a letter uppercase, we have to alternate between thinking of it as a letter, as a number, and as a bit-pattern. In Figure 2-4 you see a subroutine that returns the letter addressed by register HL as uppercase. There are two point-of-view switches in it.
uppit: ; returns A = m(hl) as uppercase
mov a,m
cpi 'a' ; lowercase?
rc ; (no, less than 'a')
cpi 'z'+1
rnc ; (no, >= 'z')
ani 1101$1111b ; yes, make uppercase
ret
Figure 2-4. A subroutine to make an ASCII letter uppercase.
The first five instructions do a range-check on the letter and exit if it isn't a lowercase letter. The second and third compare the letter to the constant a and return if it's less. The alphabet, as we usually think of it, doesn't recognize the concept of "less"; even so, up to this point most people would agree that the program is dealing with alphabetic letters.
In the fourth instruction we shift to another view of the letter. Here we want to test the letter against z but, in order to set the Carry flag for all cases when the letter is less than or equal to z, we has to test against a value greater than z. The expression 'z'+1 only makes sense if we think of z as a number, not a letter.
The subroutine finally makes the letter uppercase by setting its bit 5 to zero. That operation only makes sense if we think of the letter as a pattern of bits. After long experience with assembly language, I find such shifts in point of view very natural, but they do give newcomers a lot of trouble.
It is a mental strain to work with materials whose shape depends on our angle of view, and every increment of mental stress adds to the time-cost of making a program. But the strain is only present while we are designing and coding the operation. If we code it once in a subroutine like Figure 2-4, we may then forget all its internal details. The symbolic name of the subroutine becomes the abstract function "make an uppercase letter." Thereafter we need only write
lxi h,LetterByte ; point to letter
call uppit ; get it as uppercase
without regard to the details of the process. Naive writers on programming will tell you to use subroutines because they make the program smaller (the instructions don't have to be repeated at each place they are used). That's true. However, the true economy of a subroutine is that it reduces the mental load on the programmer. From that point of view, it can be economical to create a subroutine that is used only once.
The toolkit used in this book contains a number of functions that, like Figure 2-4, modify or classify single characters.
A lone character has limited uses. Often we need strings of them. A string can be a message, or part of one; it can represent one line of a file; and tables of strings are helpful for decoding complicated user input. Most high-level languages recognize the string as a fundamental data type. Assembly language does not; it supports only independent bytes. However, it gives us the tools we need to define our own string data type. We only have to decide how a string should be represented in storage.
There are two common ways of representing strings. In both, a string is a sequence of letters in adjacent bytes. In one method, the string is preceded by a count of the bytes in the string. For instance, a 6-byte string that could be as long as 10 bytes would be defined this way.
str: db 6 ; current length
db 'abcdef' ; contents
ds 4 ; unused space
This representation of a string is used by several Pascal compilers, but hardly ever in assembly language. You can spot the languages that use it; they restrict strings to a maximum length of 255 bytes because they have to fit the count into the leading byte. This string form has the advantages that it is easy to discover the string's length (just pick up the count) and it's easy to find the end of the string (add the string's length to its address). But nothing else you might do with a string is easy because you are forever patching up the length-bytes.
The other method leaves the length of the string implicit, marking its end with a special delimiter that can't otherwise appear in a string. A variety of delimiters have been used. The CP/M BDOS marks the end of a string with the ASCII currency symbol in some of its functions. Some programmers, when space is at a premium, will mark the end of a string by setting on the high bit of the last character. But the most common delimiter is the null byte, a byte with a numeric value of zero.
The same constant string (six bytes in a 10-byte string space) would be defined this way, using a null delimiter.
str: db 'abcdef'; contents
db 0 ; delimiter
ds 4 ; unused space
With this convention, it takes time to find the end of a string or to learn its length (you have to inspect each byte in turn until you find the null byte). But it is easy to copy a string (just copy bytes until you have copied the null) and to append one string to another (find the null in the first, then copy the second through the null that ends it).
It is particularly easy to compare two strings in this format. A subroutine that does it appears in Figure 2-5. One nice feature of this string format is that when one string is a leading substring of the other, the shorter one will automatically appear as "less."
; STRCMP: compare the strings addressed by DE and HL.
; Return the CPU flags set as for first minus second, and
; return the pointers updated to the first inequality.
strcmp: ldax d ; first string's byte
cmp m ; ..versus second string
rnz ; (unequal)
ora a ; equal ending nulls?
rz ; (yes, return Z true)
inx d
inx h ; try next bytes
jmp strcmp
Figure 2-5. A subroutine to compare two strings
Null-terminated strings have the nice property that every trailing substring of a string is also a string. The string abcd0 (using 0 to stand for the terminating null) is a string, but so are bcd0, cd0, and d0. So indeed is the terminal null byte; it's the null string. This property means that, for example, if we are pointing to a string that has leading blanks, we can increment the pointer past the blanks and still be pointing to a string. It might now be the null string, but otherwise it needs no special handling.
This string type is a very useful abstraction. The subroutines in this book include a comprehensive set of tools for creating and manipulating strings in assembly language.
Only two numeric data types are native to the Z80 CPU: the unsigned 8-bit integer and the unsigned 16-bit integer -- in other words, the binary byte and the binary word. The only arithmetic it can do on them is to to add or subtract them, or increment and decrement them by one. A lot can be done with only these abilities, but sometimes we need more.
Sometimes we need other kinds of numeric data. One useful type is the signed integer, a number that can record negative as well as positive values. Occasionally we need the operations of multiplication and division. Multiplication and division of unsigned values can be done with subroutines, and all four arithmetic operations on signed values require subroutines. Subroutines to multiply and divide unsigned integers, and to add, subtract, compare, multiply, and divide signed integers, are part of the toolkit developed in this book.
When two 16-bit integers are multiplied together, the result may be as much as 32 bits long. An integer of this size is an awkward thing for the Z80 to handle. It takes more than half of the CPU's registers just to hold it! Nevertheless, the data type of the 32-bit (4-byte) integer turns out to be useful, especially in file operations.
Under CP/M 2.2, a disk file may be as long as eight megabytes: 8,388,608 bytes, to be exact. Under CP/M Plus, it may be four times as large, or 33 million bytes. If we want to note our position in a file exactly to the byte -- and we do -- we must be able to note a number that may occupy 25 bits. If we want to convert a byte-address within a file to a record count, we have to be able to divide such a number by an integer.
Accordingly, our toolkit must contain at least some support for working with 32-bit unsigned integers, or "longwords" as we will call them. The toolkit routines for 16-bit multiplication return longwords; there is a routine to divide a longword by a word; and there are routines to add, subtract, and compare longwords.
These numeric types -- bytes, signed and unsigned words, and longwords -- are all binary numbers. We must have a means to convert between the binary representation of a number and the ASCII digits that we can display, print, or type on a keyboard. This is where the numeric data types and the string data type come together. The toolkit contains a set of functions to convert strings of ASCII digits into binary values, and vice versa. If we have a string of digits (either decimal or hexadecimal), we can convert those characters into the binary number they represent. If we have developed a binary number, we can convert it to digit characters and append them to a string.
The file is another abstract data type. The bulk CP/M itself is concerned with supporting this abstraction. To a CP/M program, a file is a sequence of bytes that it can create, write, read, rename or erase by calling on the BDOS with appropriate arguments.
This a great convenience. The operating system's file services shield us from a world of messy details -- we needn't worry about disk space allocation, or sector and track numbers, or sector sizes, or I/O interfaces. All we have to do is point to a CP/M file control block and say, "do it, please," and it will be done. Furthermore, the same "do it" request will work correctly in another system with different disk hardware, and under other versions of the operating system such as MP/M.
Useful though it is, the CP/M file system has some shortcomings. Although it claims to present a file as a sequence of bytes, it really stores it as a sequence of 128-byte records. There are too many places where we have shift points of view, thinking of a file at one time as a sequence of bytes and at another as a sequence of 128-byte blocks.
Another problem is the lack of device independence. Under CP/M, there is one set of service requests for dealing with files and a completely different set for dealing with the console, printer, and auxiliary devices. That's too bad, because with few exceptions these character devices could be treated just like files, and there are many advantages to doing so.
Another failing is that there is no connection between the file abstraction and the other abstract data types, especially integers and strings. We'd like to be able to read or write data in units that are strings, or words, or longwords, but the only data unit that CP/M can transfer is the 128-byte record.
There is also the problem of filespecs, the command tokens that the user enters to name a file. The user names a file as "d:filename.typ," but the program must parse that string into the file control block before CP/M can use it. Not only is this a tedious bit of processing, but the program must also check the string for validity (CP/M won't), and must check to see if it is ambiguous.
A related problem is the sticky one of file passwords. CP/M Plus supports them while CP/M 2.2 does not. Under CP/M Plus, a complete filespec is "d:filename.typ;password." The program must recognize the password and take special steps to present it to CP/M when the file is opened, or to assign it when a file is created. Furthermore, a good program should be able to tell when a file password is needed and prompt the user to enter it -- but only if it was omitted. All these filespec problems make it a major operation to get a file opened for processing.
All of these problems can be solved, and their solution is a major feature of the toolkit presented in this book. It contains a set of functional abstractions for dealing with files, abstractions that make of a CP/M file a true abstract data type that interacts smoothly with the other abstract types. The design details are complicated, and we'll defer them to the second part of the book. In the first part we will concentrate on using files.
In order to use an abstract file, you have to use assembler macros. But macros and relocation are separate topics. Let's turn to them now. We will return to files in the the next chapter.
Every assembler gives us two tools for abstraction, symbolic names and subroutines. A macro assembler gives us a third: the macro. In essence, a macro is a packet of assembler statements that we can invoke with a single line. We define the macro just once. Wherever we want that packet of lines inserted in a program, we write only the name of the macro. The assembler replaces it with the lines we defined for it.
Let's demonstrate this with an example. Recall Figure 2-4, the subroutine named uppit. Suppose that, for some reason, we expected to have to make the byte Letter uppercase at several places in a program. Furthermore, uppit doesn't affect the byte in storage; it returns the altered byte in register A. Therefore at several places in the program we are going to have to write this sequence of lines
lxi h,Letter
call uppit
mov m,a
Besides, at some of those places, we won't want the previous contents of registers A and HL destroyed when this is done.
Very well, we will write a macro and name it letterup (Figure 2-6). That done, we can write the single line letterup wherever we intend to make the value of Letter uppercase. The assembler will replace the one line with the seven lines defined for the macro. This is called "macro expansion"; the assembler "expands" our one-line macro call by replacing it with the body of the macro definition.
letterup macro ; open macro definition
push psw ; save A
push h ; save HL
lxi h,Letter; point at byte
call uppit ; get as upcase
mov m,a ; put back
pop h ; restore HL
pop psw ; restore A
endm ; close macro defn.
Figure 2-6. A macro to make the byte Letter uppercase.
There are several ways to think about what we have accomplished with Figure 2-6. The simplest is to say that we have used the assembler to save ourselves time at the keyboard, and we have.
Another way is to say that we have used the assembler to create a new machine instruction, the letterup instruction. It's a special-purpose instruction which happens to be composite, comprising seven ordinary instructions.
The most general way to think about it is to say that we have used the macro feature to create a new abstract function. As a result of our coding the macro, the name, letterup, has come to stand for the function, make-the-byte-Letter-be-uppercase. Wherever in a program we need that function, we need only code the name.
Notice that the letterup abstraction has been built upon another abstraction, the one implemented by the subroutine uppit. The macro provides the function of the subroutine plus saving and restoring registers. We will often build one abstraction on another until we have produced a whole heirarchy of them.
If that were all there was to macros, they would still be useful, but there is more. A macro can be written to take parameters (or "arguments" or "operands" -- the words should have different meanings, but they've been so abused that they mean almost the same thing).
A macro parameter is a portion of the macro that is to be supplied at the time the macro is processed. When we define the macro, we refer to the parameter by a symbolic name. When we use the macro, we supply data to be substituted for the parameter-name wherever it appears in the macro expansion.
Parameters let us make a macro general rather than specific. We can demonstrate this by rewriting Figure 2-6. A macro that operates only on a byte name Letter is specific to that label. We should define instead the macro upany (Figure 2-7). This macro takes as its only parameter the address of the byte to be made uppercase.
upany macro which ; parameter-name is "which"
push psw ; save A
push h ; save HL
lxi h,which ; point at byte "which"
call uppit ; get as upcase
mov m,a ; put back
pop h ; restore HL
pop psw ; restore A
endm ; close macro defn.
Figure 2-7. A parameterized macro to make any byte uppercase.
Compare Figure 2-6 and Figure 2-7. They differ in only two lines. In Figure 2-7, the first line notifies the assembler that this macro takes a single parameter whose name is "which." In the fourth line there is a reference to which; when the assembler expands the macro it will replace the characters "which" with whatever characters the programmer wrote following the name of the macro. At one point in the program we might write
upany Letter
with the result that the assembler will produce the line
lxi h,Letter
At another point we might code
upany String+17
and the assembler will dutifully produce
lxi h,String+17
as one of the lines of the macro expansion. In this way a macro can become a general functional abstraction.
The Digital Research assemblers support conditional assembly, a means of controlling the assembler so that different code is produced depending on the contents of the source program. This finds its greatest use within macros. One common use is in checking the parameters of macros. As shown in Figure 2-7, the upany macro doesn't check its parameter in any way. If we thoughtlessly omit the parameter,
upany
the assembler will replace the parameter name "which" with the null string, producing the line
lxi h,
Then it will discover that it can't assemble such an instruction and flag that line as an error. But the cause of the error won't be obvious. We can trap the error and issue a diagnostic message if we rewrite the macro as shown in Figure 2-8.
upany macro which ; parameter-name is "which"
if not nul which
push psw ; save A
push h ; save HL
lxi h,which ; point at byte "which"
call uppit ; get as upcase
mov m,a ; put back
pop h ; restore HL
pop psw ; restore A
else ; "which" was null
+++ Address of byte is required
endif
endm ; close macro defn.
Figure 2-8. A parameterized macro with a check for an omitted parameter.
The assembler will expand all of the lines of the macro, replacing every occurrence of which with the given parameter, just as before. After expanding all eleven lines, it will process them just as if they had been written into the program at that point.
Now, however, the lines comprise a conditional assembly -- one bounded by if, else, and endif lines. The assembler will process the if line, which instructs it to test a condition. The condition in this case is "not nul" followed by whatever parameter was given. That condition is satisfied if any nonblank characters follow the word nul. If a parameter was given, the condition is true, and the assembler continues working up to the point at which it finds the else line; then it skips lines until it finds endif. The effect of the macro expansion will be exactly the same as if we had used the macro of Figure 2-7.
If we omit the parameter, however, something different will happen. The assembler will still expand all eleven lines of the macro body and begin to process them. But the condition of the if line will not be satisfied. After expansion, it will consist of only
if not nul
because no parameter was given and so which was replaced with the null string.
Since the condition is not satisfied, the assembler will begin skipping; it will not assemble anything until it has seen the else line. When it begins assembling again, it will find the line
+++ Address of byte is required
That is not a valid assembly statement. It's a deliberate error. The assembler will display it with an error flag. The line doesn't say anything to the assembler or the CPU, but it gives the programmer a better handle on the source of the problem.
The conditional assembly feature can be used within macros to make them more useful or more flexible. To see how, let's rewrite the upany macro once more.
As written in Figure 2-8, the macro abstracts the idea of "make a named byte in storage uppercase." That might be handy, but there are times when we want to make a byte uppercase when we can't refer to it by a symbolic expression. Perhaps we have computed its address by adding together a base address and some other factor. Now its address is in register HL, but we can't give a symbolic name for it, and therefore can't use the macro. We could just write the subroutine call instead:
push psw ; save A
call uppit
mov m,a
pop psw
Besides being tedious, that might confuse a later reader who would waste time figuring out why we didn't use the macro here.
We'd like the macro to handle this case. We need a way to tell it that the address has already been loaded into HL and therefore it needn't generate the instructions to save and load HL. The only way to do that (with the Digital Research assemblers; others have better methods) is to define a special symbolic name, and to test for that name in the macro. Suppose that, early in the program, we had defined the name +H to the assembler:
+H equ 0FFFFh
If we can assume that's been done, we can rewrite the macro as shown in Figure 2-9.
upany macro which ; parameter-name is "which"
if not nul which
push psw ; save A
if which ne +H
push h ; save HL
lxi h,which ; point at byte "which"
endif
call uppit ; get as upcase
mov m,a ; put back
if which ne +H
pop h ; restore HL
endif
pop psw ; restore A
else ; "which" was null
+++ Address or +H required
endif
endm ; close macro defn.
Figure 2-9. A macro that uses conditional assembly.
If we give this macro a parameter that is any expression other than +H, it will produce exactly the same lines as the one in Figure 2-8. But if we invoke it with the line
upany +H
the assembler will find the conditions of the two inner if statements to be false. As a result, it will skip assembling the lines that save, load, and restore register HL. The special parameter +H serves to tell the macro, "don't bother saving and loading the address, I've done it for you."
(This technique works with the assembler named mac, but it does not work as shown with rmac. A more involved method, described in Part II, is really required.)
All of these macro techniques, and some that haven't been mentioned, have been put to use in building the tools in this book. To begin with, every function in the toolkit is accessed by way of a macro. These macros validate their parameters as fully as they can, and produce specific messages when necessary parameters are omitted. Most of them use the technique of special names for registers.
The contents of the macros are explained at length in Part II of the book. However, you don't have to know their contents in order to use them as we will be doing here in Part I; you only need to know how they are employed.
The toolkit macros are defined in a single macro library named environ.lib. It is made part of an assembly with the single statement
maclib environ
The assembler primes itself with the toolkit macros before continuing with the assembly. The environ maclib contains
The macros, their functions, and their parameters are listed in an appendix and are summarized for quick reference on the end-papers of the volume.
Simple assemblers like asm provide symbolic names and conditional assembly. Macro assemblers like mac add the ability to define and use macros. There is a third level of capability and it, too, enhances our ability to create abstractions. This is the ability to construct independent modules, and to create a final program by linking an assortment of modules together. An assembler that supports this is commonly called a relocating assembler -- although relocation is the least important part of it.
To a simple assembler, a program is a single source file and it is assembled in a single run of the assembler. There is no relation between the code produced in one assembly and that produced in another. As a result, the output of an assembly has to be a complete, self-contained program. Every function required by that program has to be part of its source text, because there is no way (or at least, no easy way) to combine the output of two different assemblies into a single program.
A relocating assembler does not force this assumption on us. The input to a relocating assembler does not have to define a self-contained program. It may be the source of a module: a portion of a program that is logically separable from other parts, but not complete in itself. The assembler provides three features that, together, make it possible to link together the output of many assembly runs into a single program.
The first two features have to do with symbolic names. We may tell the assembler that a particular name is external, that is, not defined by any statement in the current source file. The assembler takes it on faith that the given name will be defined somewhere, sometime, in another assembly. It includes in its output a note to the effect that name so-and-so is required to complete this module. It also writes notes to designate all the places in the object code where the address of name so-and-so is used in an instruction.
The complement to an external name is a public name. A name that is defined within the current source file may be be designated as public. This tells the assembler that access to the name will be required by other modules. The assembler does nothing special with the name except to place in its output a note to the effect that name such-and-such is defined at a certain offset in this module.
These special name-handling features are almost enough to allow modularity. Already you can imagine a program that would read the object code of two or more modules and splice together their name-references. It could see what names were defined as public, and what names were required as external references; then it could supply to the external references the addresses that correspond to their definitions.
Let's illustrate this with a simple example. We will begin by converting our uppercase subroutine, Figure 2-4, into a complete assembly source file (Figure 2-10). This requires only the addition of a public statement to make the entry-point label of the subroutine available to other modules.
Two other, optional, lines have been added in Figure 2-10: a name statement causes the module's name to be part of its object file, and an end statement tells the assembler the source is complete. The assembler doesn't require the end line; the physical end of the file would serve. The name statement names the module within an object library.
name uppit ; module name
public uppit ; entry-label is public
uppit:
mov a,m
cpi 'a' ; lowercase?
rc ; (no, less than 'a')
cpi 'z'+1
rnc ; (no, >= 'z')
ani 0ffh-20h; yes, make upper
ret
end
Figure 2-10. The uppit subroutine as a complete module.
If it were placed in a file uppit.asm, the source text shown in Figure 2-10 could be assembled by rmac. The output would be another file, uppit.rel, which defines the object code of the subroutine. It isn't a complete program; it doesn't do anything that would be useful as a CP/M command. It is, however, a logically-separable part of a program, one that realizes the functional abstraction of converting a letter to uppercase.
Now we need a way to combine this module with another program. Suppose we were writing a program in which we had need of uppit. Here is part of it:
extrn uppit
lxi h,LetterByte
call uppit
The extern statement tells the assembler that the name uppit is not defined anywhere in the current source file. Instead we promise, cross our hearts and hope to die, that the name uppit will be defined as public in some other source file. The assembler believes us. When it assembles the call uppit statement, it doesn't try to put an address in the call instruction. It puts only zeros there, and inserts a note in its output saying "this call instruction won't work until it has been patched to point to something called uppit."
The program that weaves together these interlocking name-references is called link; it is part of the rmac software package. It processes the output of one or more assemblies to produce a runnable program. We could finish our program with the command
A>link program,uppit
The link program would read program.rel and uppit.rel and combine them. It would note the public name uppit in one and the external reference to the same name in the other, and it would patch the call instruction so that it pointed to the code of the uppit subroutine. Its output would be the file program.com, ready to execute.
Notice what this gives us. The subroutine uppit only needs to be written once; in it, we put our idea of how a certain abstract function is to be implemented. It needs to be assembled only once, also. After all, once we have defined how to do this task, how often will we need to change it? The tiny file uppit.rel is all we need. In any program where we need the function, we can write an extrn statement for uppit, and include uppit when we link the program. Even that can be simplified, as we'll see.
But we have left a loose end. We actually use uppit by way of the macro upany (Figure 2-9). It's the macro name that represents the function to us, not the subroutine name. It's all too likely that we'll employ the macro without remembering to declare the subroutine as external. If we do, the call statement in the macro expansion will produce an assembly error type "U" because the name uppit will be undefined.
This is easily fixed. All we have to do is to add the extrn statement to the macro body (Figure 2-11). When the macro expands, the statement will be processed. The assembler will note the need for uppit in the object file, the linker will add it to the finished program, and all will be well. If we use the macro several times, several extrn statements will be generated. That causes no problems with the Digital Research assembler and linker, although other products might prohibit it.
upany macro which ; parameter-name is "which"
if not nul which
push psw ; save A
if which ne +H
push h ; save HL
lxi h,which ; point at byte "which"
endif
EXTRN UPPIT
call uppit ; get as upcase
mov m,a ; put back
if which ne +H
pop h ; restore HL
endif
pop psw ; restore A
else ; "which" was null
+++ Address of byte is required
endif
endm ; close macro defn.
Figure 2-11. A macro that calls an external subroutine.
I have glossed over the linker's other task, relocation. Consider: at the time it is processing a source file, the assembler cannot possibly know where the object code is going to be located in storage. Nor can we know it. Oh, if the source file represents the main part of a CP/M command, we know that it will have to be located at 0100h, the base of the CP/M transient program area. But when we are assembling a subroutine like the one in Figure 2-10, neither we nor the assembler can tell where the code will finally reside. It depends on the size of the program to which the subroutine will be appended. By the same token, the assembler can't predict where an external name will be when the program is finished.
Many Z80 instructions, especially jumps and calls, contain operands that are 16-bit address values. These addresses depend on the final location of the code that they refer to. Yet the assembler cannot know where the code will be! How can it finish these instructions?
The answer, of course, is that it doesn't finish them. It leaves room in the object code for the addresses, but it doesn't try to put values in them. It does include notes in the object file that say, in effect, "this word refers to an offset of x bytes within this module." Everything else is up to the linker.
The linker is the program that knows the final location of each piece of code. It puts the first .rel file at address 0100h; all the rest follow it. As it prepares each module, link updates all the addresses so they refer to the location of their final targets.
The rmac product is composed of three programs. We've already discussed the assembler, rmac, and the linker, link. The third program is a library manager, lib.
Its purpose is to build and maintain libraries of object modules like uppit.rel. It is easy to accumulate a collection of dozens, even hundreds, of useful little modules. It is awkward and inefficient to keep each one as a separate .rel file and to name it in the link command. Instead we collect them in related groups as object libraries ("relocatable libraries" in the Digital Research documentation). The lib command is used to build an object library, to list its contents, or to replace a module within a library.
The lib program will build either of two types of library files. Both have the same contents, a collection of modules. The simplest is just a concatenation of the modules that go into it. Such a library has the filetype .rel.
The second type is called an "indexed relocatable library" and has the filetype .irl. A file of this type commences with a table of module-names and the file offsets at which they commence. Both the lib and the link program can use this tabular index to locate a module quickly.
Suppose that we already had an object library named misc.irl. We could add the file uppit.rel to the library with this command.
A>lib misc.irl[i]=misc.irl,uppit
The lib program's peculiar syntax becomes more understandable if you keep in mind that it always builds a new file from one or more old files. The new file is named on the left of the equals sign. The old files that go into it are named in a list to the right of the equals sign. The command above can be read as "Lib: create misc.irl (which is to be an indexed relocatable library) from the contents of misc.irl plus uppit.rel."
Notice that giving a filetype of irl to the new file is not sufficient. You have to include the option [i], or lib will build an ordinary library.
Should we alter uppit and reassemble it, we could update it in the library with this command.
A>lib misc.irl[i]=misc.irl<uppit>
Read that as "Lib: create misc.irl (which is to be an indexed relocatable library) from the contents of misc.irl, replacing the module whose name is uppit with the file named uppit.rel." (We put name statements in module source files, as in Figure 2-10, so we can refer to them in such commands.)
When we have uppit in an object library, we can link a program that uses it with this command.
A>link program,misc.irl[s]
The option [s] after the library name means "selective search"; it tells link to include only the modules from this library that are specifically required. Lacking the option, link will include everything in the library whether it is referenced or not.
The toolkit used in this book is kept in one object library, environ.irl.
We have achieved the acme of abstraction allowed by the Digital Research assembly products. We have defined an abstract function, that of making a byte uppercase. We implemented the simplest, most general case of the abstraction as assembly source, then stored its object code in an object library.
We defined a macro as an interface to the abstract function. The macro builds on the simple subroutine by saving, loading, and restoring registers. The macro also declares the subroutine as external so that link will include it.
When these steps have been done, all we need to do in order to use the abstract function is to include the macro library that defines it in our programs. Well, we do have to remember the name of the macro and what it wants in the way of parameters. However, all that's needed to invoke the function is to write of the macro name and its parameter. The macro will take care of setting up the registers that the subroutine needs. The assembler will note that the subroutine is needed. The linker will search for it and append it to the linked program.
In the next chapter we will experiment with these tools by writing some simple file-copy programs, and we will study some of the features of the file abstract data type
We have one more element of the programming craft to discuss. A craftsperson draws a plan before starting work. That step is crucially important in programming. Here we will write write a small program, using it as an example of planning and of using the toolkit macros.
For a first exercise, I propose a program that is very simple, a mere finger-exercise, yet one that is quite hard to do well in any but assembly language. Furthermore, I have seen programs like it advertised for sale for $25, so right here we are going to justify the cover price of this book.
The purpose of the program is to accept lines of input from the keyboard and duplicate them on the printer -- in other words, to turn your expensive CP/M system into a mere typewriter. There are good uses for such a program. You can use it to address an envelope, for instance, or to put a note on a listing.
It is possible to drive the printer from the keyboard using standard CP/M commands. The Control-P keyboard signal makes the BDOS echo each command and its output on the printer. That has its uses, but it won't let you address an envelope; at least, not unless you want each line of the address to commence with "A>."
You can drive the printer from the keyboard with pip by giving the command
A>pip lst:=con:
Unfortunately, pip reads keyboard input one byte at a time. When a program does so, none of the normal input editing keys work. If you make a typo, the typo is printed at once. If you backspace to correct the typo, the backspace is sent to the printer, followed by the overstruck correction. (Many printers ignore backspaces, so both letters appear, making things even worse.) Furthermore, pip echoes only the keys it receives. In particular, when you end a line with the Return key, only the Return code is sent. The Linefeed code that CP/M normally adds isn't sent. So you have to press both Return and Linefeed at the end of each line.
What we want, then, is a program that will read complete lines from the keyboard, permitting the full range of line editing that the BDOS supports. Support for line editing is especially important with CP/M Plus, which allows you to make corrections at any point in the line. It's still useful with CP/M 2.2, which at least permits you to backspace to an error. When the line is complete, the program is to copy it to the printer. This process is to be repeated until we are done.
The preceding paragraph is a plan of sorts, but it has to be formalized. In Figure 3-1 you will see a first pass at a formal plan for the typist program.
typist
Keyboard is a file that represents the console.
Printer is a file that represents the printer.
Line is a space for a string of characters.
repeat
read a line from Keyboard into Line
write Line to Printer
until done
end.
Figure 3-1. First-draft pseudo-code of typist.
That plan is rendered in pseudo-code, a notation for planning programs. A pseudo-code is any notation that mixes the few, rigid, control structures allowed by good programming practice with free-form words whenever we want to defer thinking about irrelevant details.
A pseudo-code plan is for use by people, not by programs, so it may use any notational conventions you like. My personal pseudo-code style mixes English with forms taken from real programming languages. You are free to develop your own pseudo-code style, one that suits you taste and the way you think. The style should force you to state the shape of the problem in the formal, explicit manner of a programming language while not impeding your thinking with excess detail.
There are four reasons to write a plan in pseudo-code. The first is that it forces discipline on a verbal specification, giving it structure. That structure reveals much more precisely what the program will entail. Often a problem that sounds simple in English turns out to be very difficult to do. It's better to find that out while planning than while coding.
The second reason is that we make discoveries as we labor to translate fuzzy words into the strict forms of a program. We discover which things in the program will be hard to do and which easy. We discover that down here we will need to use something that we could have stored back up there. We discover that we will have to do the same procedure at three different places, and therefore it should be a subroutine. Or we discover that we have to do in this program something that we already worked out while planning an earlier one.
The third reason is that we can experiment with a pseudo-code plan. It's close enough to a program that we can make initial judgements about the algorithm we will use. We can write two versions of a procedure and decide quite accurately which version will run faster or use more working storage. We can erase, cross out, revise in every way; and we can do it with much faster "turnaround time" than if we were doing the same experiments in a real programming language. The flexibility and power of the pseudo-code "editor" and "compiler" are really amazing when you consider that they consist of nothing more than a yellow pad and a felt-tip pen.
The last reason is that the actual program is much easier to write when we can work from a good pseudo-code plan. One reason assembly language is hard to use is the number of niggling details that we have to keep track of: the contents of the registers, the correct jump condition to use, and so on. But these matters are local problems that can be worked out while looking at a span of code no larger than one screenful. They aren't difficult to solve, provided we are not at the same time trying to solve global, algorithmic problems. It's just not possible to work out the correct use of the stack and the shape of the main loop at the same time. But if you have made all the big decisions and recorded them as pseudo-code, the coding details are easy to cope with.
Let's return to the design of the typist program. The first draft of a plan appears in Figure 3-1, and it is nearly enough to code from. (I can say that because I know there are toolkit macros that correspond to each of the major lines in Figure 3-1.) There's only one change that should be made in it. The loop condition, "until done," is vague. When is "done"? The answer requires a digression on the nature of the file abstract type.
One of my objectives in designing the file-handling tools was that all files were to be handled alike, whether they were disk files or CP/M character devices. As much as possible, an input file was to be an input file regardless of whether its contents came from a disk, or the keyboard, or a modem. And it worked out that that could be done (the details are in Part II).
Another principle was there were two types of files, ASCII and binary. An ASCII file contains ASCII characters grouped in lines, and its end is marked by the Control-Z byte or by physical end of file, whichever comes first. A binary file contains any sort of bytes; has no internal structure except as the program imposes one; and it ends only at physical end of file. The difference between these two types of file lies entirely in how the program treats them. If it reads a file with ASCII-type macros, the file will be given ASCII-type treatment; if not, not.
Now, it is my plan in typist to treat both the console and the printer as ASCII files. That means that the end of the input file will be marked by receipt of a Control-Z byte. (It can't be marked by physical end of file, because there is no such thing as the "end" of a device.) The functions that read ASCII data return a signal when they find a Control-Z byte.
That is what "until done" amounts to, then: the appearance of logical end of file on the input, as signaled by the function that reads a line. We can express this in the plan by rewriting it as shown in Figure 3-2. In it, the pseudo-keyword break means "exit the loop right now."
typist
Keyboard is a file that represents the console.
Printer is a file that represents the printer.
Line is a space for a string of characters.
loop
read a line from Keyboard into Line
if end-of-file, break
write Line to Printer
repeat
end.
Figure 3-1. Final draft pseudo-code plan of typist.
Since it doesn't do any processing of the data, the typist program consists almost entirely of calls on toolkit functions. As a result, its assembly code (Figure 3-3) is almost a line-for-line translation of the plan. Read it now, then let's examine it together.
; TYPIST -- echo keyboard lines to printer
maclib environ
dseg
linesize equ 128 ; max line we will read
keyboard:
confile
printer:
lstfile
line: strspace linesize
cseg
prolog
loop: fgetstr keyboard,line,linesize
rz
fputline printer,line
jmp loop
end
Figure 3-3. Source code of the typist program.
Many of the lines of the program are calls on toolkit macros. In a minute we will talk about these in the order they are used. Remember, however, that all of these macros are documented in the Appendix to this book.
Figure 3-3 is, of course, an assembly source file. It is written for assembly by rmac, a relocating assembler. That explains the two statements dseg and cseg, which you may not have seen before. Relocating assemblers distinguish between a "data segment" and a "code segment." These portionss of the program are kept separate. At any point in the program the assembler is producing object code for one segment or the other, and these statements switch between them.
The program begins in the data segment by defining two files with the statements
keyboard:
confile
printer:
lstfile
The words confile and lstfile are the names of macros. They are declarative macros; that is, macros that expand to lists of static data. Declarative macros are normally placed in the data segment, although they don't have to be.
Each of these declarations expands into a "file control record," an assemblage of bytes and words that will represent the file to the toolkit functions. There is a general-purpose macro for declaring a file; it is named filedef and we will use it later. The macros used here are special cases of filedef that make it easy to define files that specifically represent the console and the printer.
The line
line: strspace linesize
calls on another declarative macro. The strspace macro declares space for a string of some length -- in this case a length of linesize, or 128 bytes. The macro assembles to a single null byte plus the requested amount of undefined space. In other words, it reserves room for a string of that size, and initializes the space to the null string.
Declarations done, the program switches to the code segment, where executable instructions are always assembled. It invokes the prolog macro because a call on prolog must be the first executable statement in any program that uses the toolkit. This macro expands into a few instructions that
and then call the instructions that follow prolog as a subroutine. Everything that follows prolog is a subroutine of it; therefore the main program may end with a simple return instruction. When it does, the remaining instructions in the prolog expansion return to the operating system. Under CP/M Plus, they also set the Program Return Code to a value of "success."
The formalities complete, Figure 3-3 gets down to the business of the program, which takes exactly four lines to conduct. Here they are:
loop: fgetstr keyboard,line,linesize
rz
fputline printer,line
jmp loop
Two of these are macro calls. The fgetstr macro gets a string from a file. It requires three parameters: the address of the file, the address of a space to receive the string, and the maximum length of the space. In this macro the keyboard file is passed as the file, the line string space will receive the data, and the constant linesize is used to specify its maximum length. (In Figure 3-3, find the three places where linesize is named. Do you see its purpose?)
Reading a string is an ASCII-type operation. The fgetstr function takes bytes from a file and stores them in a string until it sees a Return byte, or a Control-Z byte, or until the string space fills up. If it stops on a Control-Z byte, it returns the Zero flag true; otherwise it returns it false. As a result, our pseudo-code statement "if end of file, break," can be implemented as the instruction rz, meaning "if fgetstr saw a control-Z byte, quit."
As you can probably guess, the fputline macro writes a string to a file. It also writes a terminating Return and Linefeed pair. (There is a function fputstr that writes the string but not the Return and Linefeed. These macros are related in the same way as the write and writeln procedures of Pascal, or like BASIC's PRINT statement with and without a terminating semicolon.)
The source file of Figure 3-3 can be assembled with a command such as
A>rmac typist $lb px sz -m
You may not be used to assembler options. Their meanings are
The last option simplifies the listing; without it the tedious and uninformative macro expansions obscure the listing. (I'm not trying to hide anything; feel free to display the macro expansions if you want to. You'll soon find that they clutter your neatly-typed logic without adding any information.)
You can see the result of that assembly command in Figure 3-4. It doesn't add a great deal to the source file; so little, in fact, that I almost never keep an assembly listing. A listing of the source file is sufficient as a record of the program.
A>rmac typist $lb px sz -m
CP/M RMAC ASSEM 1.1
; TYPIST -- echo keyboard lines to printer
maclib environ
dseg
0080 = linesize equ 128 ; max line we will read
keyboard:
0000 confile
printer:
00C0 lstfile
0100 line strspace linesize
cseg
0000 prolog
loop:
0037 fgetstr keyboard,line,linesize
0047 C8 rz
0048 fputline printer,line
005C C33700 jmp loop
005F end
005F
076H USE FACTOR
END OF ASSEMBLY
Figure 3-4. Console output during the assembly of Figure 3-3.
As soon as the assembly is complete, the program can be linked. The resulting console display appears in Figure 3-5, and it deserves comment.
A>link typist,b:environ.irl[s]
LINK 1.31
+RAMAA 05D4
+FSGAC 01FD
+FRFIL 03CB
+FBINP 048C
+FBAIM 04EC /FSWORK/ 0653
+STAPP 05A5
+RAMCK 05E4
+RAMLK 062E
ABSOLUTE 0000
CODE SIZE 0553 (0100-0652)
DATA SIZE 03AC (06D3-0A7E)
COMMON SIZE 0080 (0653-06D2)
USE FACTOR 0A
Figure 3-5. The display produced while linking typist.
The bulk of Figure 3-5 consists of a display of all the public labels that link handled while linking the program. You might wonder what all these names signify. The answer is, very little. Most of them are the names of toolkit functions. The toolkit modules call one another with abandon; for example, FSPST, which implements the put-string function, calls FSPAC (put an ASCII character), which calls FSPBY (put a byte), which would call FBOUT if it needed to write a buffer-load to disk, and so forth.
All this has next to nothing to do with the logic of typist, the program we are interested in. There are two things worth noting. First, all the public names used in the toolkit code begin with the at-sign character. That's a device to isolate these public names, avoiding conflicts with public names you might devise.
The second thing to note is the aggregate size of this, a very simple program. According to the messages at the end of Figure 3-5, the final code segment contains 553h bytes (1,363 decimal), and the data segment contains 3ACh (940 decimal). There is a common segment (a separate, named, data segment) of 128 bytes, so the total size of the program is 2,431 bytes.
That seems like a lot for a trivial program, and it is. The typist program could be written in a tenth the space if we were willing to hand-code its file operations. Because of the way the toolkit functions cross-link with each other, there is a minimum size of about 2,500 bytes for any program that uses only device files and about 4,100 bytes in the more typical programs that write to disk. Two things make that acceptable. First, it is still small when compared to the minimum size of a program in a high-level language (compile the comparable program in Pascal or C and check its final size). Second, after this first lump, programs grow very slowly as they become more complex. The increase in size after 4,100 bytes is all in the processing code, not in the support.
Now that it exists as typist.com, we can try out the typist command. There isn't much to it (see Figure 3-6). Each time the Return key is pressed, that line is written to the printer. When the user enters a Control-Z signal, the program quits. An impatient user could also kill this program by entering Control-C; CP/M would then cancel it. We shouldn't encourage our users to do that, however. For one thing, it doesn't work with all programs. For another, Control-C cancellation takes place instantly, without giving the program a chance to clean up after itself.
A>typist
A line for the printer
another one.
^Z
A>
Figure 3-6. Execution of the typist program.
It's an important feature of typist that it permits the operator to use CP/M line editing. It only does so as a result of a special feature of the toolkit input functions. In most cases, the support for fgetstr simply copies one byte at a time from the file to the string space, stopping at the first Return character. That's what it does for a file and for the AUX character device (called the RDR device in CP/M 2.2).
The toolkit functions make a special case of reading a string from the keyboard. In this one case, they call on the BDOS for its service of reading a whole line. It is that BDOS service that supports line editing. Since the system handles it, our programs don't have to. On the other hand, the length of such a line is limited. The BDOS won't read a line longer than 255 bytes, while the toolkit function won't ask it to read a line longer than the buffer associated with the file. In the case of the confile macro, that buffer is only 128 bytes long.
Longer lines, indeed lines of any length at all, can be read from other kinds of files. Longer lines can be read from the keyboard as well, but only by requesting them one byte at a time with a different macro, fgetchar. The disadvantage is that when the keyboard is read one byte at a time, CP/M doesn't supply any line-editing features.
The typist program is a file-copy program written for the special case of copying from keyboard to printer. It has its uses, but the same abilities could be had from a more general program, one that copies from the keyboard to any destination: the printer, the AUX (or PUN) device, or to a disk file. Let's make such a program, and call it keyfile.
The purpose of keyfile is to take lines from the keyboard and write them to some destination. The destination will be named as an operand of the command; it will either be a file or one of the keywords AUX:, PUN:, or LST:.
The keyfile program will serve the same purposes as typist, but may also be used to construct a short disk file or to send a line or two to whatever is connected to the AUX device. Like typist, it will allow line-editing of the input lines. However, it should also display a prompt; that is, some signal that it is ready for a line to be entered.
Once more we prepare a plan in pseudo-code. It resembles the plan for typist but there is more to be said (Figure 3-7).
Keyfile(Destination)
Input is a file that represents the keyboard
Screen is a file that represents the screen
Output is a file
Line is space for a string
Prompt is the string "==>"
verify the command operand
prepare the Input file
prepare the Output file
loop
display Prompt on Screen
read Line from Input
if end of file, break
write Line to Output
end loop.
close the Output file
end.
Figure 3-7. Draft plan for the Keyfile program.
The main loop of Figure 3-7 is much the same as the main loop of Figure 3-3, except for the addition of displaying the prompt. The differences occur outside the main loop, in preparing the files beforehand and closing the output file after the loop ends.
One of the most convenient, and at the same time most awkward, features of the CP/M programming environment is the way that command-line operands are presented to a command program. It is convenient, in that whatever operands the operator may have typed are placed in a known location in storage. The program may parse them and use them in any way desired.
It is awkward because the characters of the command line are simply left, exactly as they were typed, and the program has to parse them into tokens before they can be used. That is not a simple task; for a program the size of keyfile, the code to isolate an operand and assign it as the name of a file can be larger than the code of the main loop. The problem becomes harder as the number of operands increases.
CP/M will have formatted the first two operands into CP/M File Control Blocks (provided they were delimited by spaces, not by tabs), but CP/M doesn't recognize the names of devices for this use; and it doesn't handle operands after the second; and anyway the first two may not have been meant for file names.
The toolkit addresses these problems through two functions. One parses the operand string into tokens, each token a separate string, and reports the number of tokens it found. The other returns the address of a token-string given its number.
With these services in mind, we can expand on the plan line, "verify the operand." What must be verified is, first, that exactly one operand was given. With no operands, the program wouldn't know where to put its output. If there are two or more, the operator has confused this program with another one.
When one operand was given, its format must be validated; that is, we have to make sure that it is a valid file specification, or filespec. That can be done by calling on the toolkit function that assigns a filespec (as a string) to a file. It reports back if the string was not valid, or if it was ambiguous. We mustn't try to use an ambiguous filespec (one like "*.doc" or "abc???.sub") for output.
If we assume the presence of these services, we can expand the plan as follows.
to verify the command operand:
parse and save command operands
if not just 1 operand, abort with a message
assign operand 1 to Output
if invalid, abort with message
if ambiguous, abort with message
The input and output files will be specified as abstract files using the general macro filedef. Such files must be opened before they can be used. Anywhere in data processing, to open a file is to do whatever is necessary to make it ready for use. In the specific context of this book's toolkit, opening a file entails several steps:
In the typist program, we used the confile and lstfile macros. Since these are specific to the console and printer devices, they are "opened" statically at the time the macros are expanded.
The Input file in Figure 3-7 will be prepared by application of the freset macro, which prepares a file for input. The file will be dedicated to the console device, so we know beforehand that freset will discover that it represents a device and that it can support its intended use, which (freset assumes) is that of sequential input.
The Output file will be prepared using the frewrite macro, which prepares a file for output. We won't know what the real destination of the file will be; it will depend on what the the operator gave as a command operand. The frewrite support might find it to be a device like LST: or a disk file. It might find that the destination can't support the implied use of sequential output. That would be the case if the command operand was a read-only device like RDR, or (under CP/M Plus) if it specified a read-only disk file. In such cases, the open function will abort the program for us.
With an expanded plan in hand we can code the program (Figure 3-8). Read it now to see how much of it you can understand or guess at. In a moment we will study each line, but try to get the big picture first.
; KEYFILE destination
; write keyboard lines into file specified by "destination"
maclib environ
dseg
Linelen equ 256
Input: filedef Linelen,,CON:
Screen: msgfile
Output: filedef Linelen
Line: strspace Linelen
Prompt: strconst '==>'
Ughhh: strconst 'I have a problem.$'
cseg
prolog
;verify the command operand Destination
; parse and save command operands
savetail ; save operands, set A to count
; if not just 1, abort w/ message
cpi 1 ; exactly one?
abort nz,Ughhh
; assign operand 1 to Output
tailtokn 1 ; set DE->string of token #1
xchg ; make that HL->token
fassign Output,+H ; assign string to file
; if invalid, abort w/ message
abort z,Ughhh ; Z true if not valid filespec
; if ambiguous, abort w/ message
cpi '?' ; A="?" if ambiguous
abort z,Ughhh
;prepare the Input file
freset Input
;prepare the Output file
frewrite Output
loop:
; display Prompt on Screen
fputstr Screen,Prompt
; read Line from Input
fgetstr Input,Line,Linelen
; if end of file, break
jz done
; write Line to Output
fputline Output,Line
;end loop.
jmp loop
; close the Output file
done: fclose Output
ret
end
Figure 3-8. Source code of keyfile.
Once more the source file follows the pseudo-code closely, since again there are toolkit macros for almost everything that has to be done. The lines
Input: filedef Linelen,,CON:
Output: filedef Linelen
are the first appearances of filedef. This declarative macro expands to a block of 64 bytes which represents a file. Half of that contains a CP/M file control block (FCB), the normal input to the BDOS for file-oriented service requests. The remainder is used by the toolkit functions that support files; it allows them to extend the CP/M idea of a file into a more general, flexible abstract data type.
The filedef macro allows five parameters. The first one is required; it specifies the size this file's buffer is to have. The buffer is a block of storage where file data can be held on its way to or from the file. The buffer space is allocated dynamically when the file is opened; it does not take up space in the program as stored on disk.
This buffer-size parameter is required, and it must be a multiple of 128 bytes. In both cases here, the buffer size is given as Linelen, that is, 256, bytes. For the Input file, that permits receiving a line of up to 254 bytes from the operator. The buffer size for the Output file didn't have to be the same size; it could have been specified larger or smaller. With the single exception of reading whole lines from the keyboard, all toolkit file functions work with any size of buffer. Generally they go faster with larger buffers, but speed isn't a concern in this program, which is limited by the operator's typing speed.
As many as four parameters may follow the buffer-size. When given, these parameters represent the file specification for this file. In full, the parameters of filedef are
filedef buffersize,drive,name,type,password
In the declaration of the Input file, only the name parameter is specified. When a file is given a filespec with the only the filename CON:, having no drive-letter, no filetype, and no password, the toolkit functions will recognize the file as referring to the console. This is how we dedicate the Input file's definition to the console device. (We might better have coded the name KBD:, which the toolkit recognizes as meaning the console device for input only.)
No filespec parameters are given for the Output file. That is because the filespec will be assigned to it later, when the program executes, from the first operand of the command.
The two lines
Prompt strconst '==>'
Ughhh strconst 'I have a problem.$'
declare constant strings. The strconst macro assembles the given constant in the form of a string, that is, terminated by a null byte so that the string functions will handle it correctly.
The code of Figure 3-8 follows the pseudo-code of Figure 3-7 very closely. Indeed, the assembly code was made from the pseudo-code: I edited the pseudo-code file, converting its lines to assembly comments, then wrote the assembly statements between them.
Before we follow the flow of logic in the code, let's consider the abort macro that appears several times. It frequently happens that, in the initialization sections of a program, we want to terminate it with a message. We don't expect the program to abort very often, and aborting is not a detail on which we want to spend a lot of time, but still we have to think about it. The abort macro makes it easy to code these cases.
The macro takes two parameters; its syntax is
abort jump-condition,message-label
The jump-condition is a one- or two-letter condition that you might code as part of a jump instruction in the case where an abort is desired. The message-label is the address of a message that is to be displayed as the program quits.
Unlike the rest of the toolkit macros -- in which I tried hard to avoid optional macro parameters -- both parameters of abort are optional. If the jump condition is omitted, the abort is unconditional; it will always take place if control reaches it. And if the message label is omitted, the macro assumes that register DE addresses the message.
The message issued by an abort is written with a BDOS service request that expects the data to be terminated with a dollar sign, $. (Notice that that was true of the string constant, Ughhh.)
When an abort takes place, its effect is to jump directly to a short piece of code that is produced in the expansion of the prolog macro. This code calls on the BDOS to display the message and then terminates the program. (Under CP/M Plus, it also sets the Program Return Code to a "failure" value.)
All of the aborts in keyfile use the same message, a very unhelpful statement that "I have a problem." In later chapters we will improve on this behavior.
With that background, we can follow the logic of keyfile. The savetail function scans the command tail that CP/M left, and breaks it into tokens wherever it is delimited by spaces or tabs. Each token becomes a string, and all the strings are put away for safekeeping in a block of storage that is allocated dynamically. The count of tokens found is returned in register A. Here, the program tests that count and aborts if it isn't equal to 1.
The tailtokn macro returns a token given its number (counting from 1). It returns the address of the token in register DE, which is the usual way of addressing strings for the string functions. However, the file functions, when taking a string as input, take its address in register HL (they reserve DE to address the file block). Accordingly, the code here puts the token address in HL and invokes the fassign macro. This function accepts a file (here Output) and a string, and assigns the string as the filespec of that file. At this point we have the address of the string in a register, and have no other way of naming it. The macro call specifies that the address of the string is already in register HL.
The fassign function reports two conditions. If the string it receives is not a valid CP/M filespec, it returns the Zero flag true. If the name was valid but ambiguous, it returns a question-mark in register A. The code here checks these two cases and aborts if either is true. The remainder of the program is a straightforward translation of the pseudo-code, and quite similar to typist.
We have built two versions of a simple, but useful, program; we have practiced using pseudo-code; and we have gone over the use of some common toolkit macros in detail. These three chapters have been a lengthy (and perhaps tedious) introduction to the fundamental tools of assembly language programming. We can list them now. The chief one is the mental tool of abstraction in all its forms. The physical tools are used to implement heirarchies of abstractions. We define some common data types and the operations on them; we implement those operations as subroutines; we hide the subroutine interfaces in macros. We use the assembler's naming and macro abilities to do this, and we use the linker and library manager to store the subroutines conveniently and efficiently.
In the rest of Part I we will write ever more complicated programs and create ever-higher levels of abstraction. In the text, I will pay less attention to the details of the tools because they are documented in the appendix. It's not the tools we are concerned with here, but what we can build with them
The program developed in this chapter, although simple, is one of the most consistently useful ones in the book. Its design gives us an excuse to use a table of strings, and to try out the conversion of strings to numbers.
Peripherals just keep getting smarter, don't they? When I first got my printer, it seemed to be the acme of sophistication because it could set its margins and tabs, and boldface and underscore, all under software control. My terminal can do all sorts of clever things provided the computer sends it the right sequence of control characters. And now there are smart print spoolers, voice synthesizers, and modems, each with its own set of sophisticated functions.
All these smart devices are controlled alike. They do their minimal, generic functions if we send them a stream of ordinary data to process, but their fancy features can only be triggered by sending them a sequence of special codes; a control sequence of ASCII control characters.
Normally, sending control sequences is the business of a special piece of software, a word processing or communications program that has been configured for the particular device. But often we would like to control the device directly from the CP/M command line without having to load an interactive program.
That is the purpose of the emit program; it will let us issue a control sequence to a device from the command line. Its command syntax will be
emit device bytes...
which may be read as "emit to device this sequence of bytes." The device will be one of the CP/M logical devices that can accept output: CON: or LST: or AUX: (also known as PUN:). We have to work out some sensible format for the bytes. Let's consider what kind of bytes go into a control sequence.
I've worked mostly with printers and terminals. These generally take three kinds of bytes in their control sequences: ASCII control characters, single ASCII letters, and bytes that are treated as numeric values. To set the left margin of a Diablo printer to column 10, for example, you send it
An intuitive syntax for bytes would let us send that Diablo sequence with a command like
A>emit lst: esc ht 10 esc 9
But there's a problem with that command. It would be difficult for a program to deduce that the operand "9" was meant as an ASCII digit character, the byte 39h, while the operand "10" was meant as a byte with the decimal value of 10, the byte 0Ah.
Letters present the same problem. An Epson MX-80 printer can be set for condensed, emphasized printing with the sequence of characters SI, ESC, and the letter "E". A natural command format for that might be
A>emit lst: si, esc, e
But it would complicate the program's logic if it had to deduce that "e" was a fundamentally different kind of thing from "esc." It could be done; the program's rule could be that if an operand starts with an alphabetic, it is a control character if it appears in a table of control-character names, otherwise it is a literal character. There are two things wrong with that, however. It doesn't solve the problem of numeric 9 versus digit character "9." And it makes a mis-spelled control character into a literal, so that a typing error like "ecs" for "esc" couldn't be diagnosed and reported.
The usual solution to this kind of encoding problem is to require some kind of quote character. Suppose we say that, to emit a literal character, the character must be prefixed with a quote. Then the two sample commands come out like this.
A>emit lst: esc, ht, 10, esc, '9
A>emit lst: si, esc, 'e
That solves the immediate problem. For neatness' sake, let's allow a quote to be closed. For the sake of flexibility and friendliness, let's not require a closing quote, and let's permit either kind of ASCII quote, thus:
A>emit lst: esc, ht, 10, '9'
A>emit lst: si, esc, "E
We can even accomodate multi-character literals, within limits. Suppose that a smart modem will dial a number if it receives "###D" followed by the number, followed by a Return character. We could drive it with a command like this.
A>emit aux: '###D415-555-1212' cr
We cannot, however, support literal spaces, tabs, and commas. Those characters are preempted by the conventions of CP/M; they delimit command operands. The toolkit functions (and any other CP/M command program) will use them as delimiters and discard them. Nor can we support lowercase letters, since CP/M will convert the command line to uppercase before our program sees it.
One last feature. Sometimes the documentation of a device will specify its control sequences in terms of hexadecimal values. We shouldn't require a user to convert a hexadecimal code in the manual to a decimal code or an ASCII value for use with emit. How shall we encode a hexadecimal number?
There are several conventions for representing a hexadecimal byte such as the value of ESCape, 1B. There is the old IBM convention, X'1B', but we can discard that out of hand. There is the convention of the increasingly popular C programming language, 0x1b. There is the convention used with assemblers for the 8080 and Z80, 1Bh, and the convention used with assemblers for the 6502 and 6800 CPUs, $1B.
It really doesn't matter which we pick. We can assume that a programmer will feel comfortable with any of them, and that a nonprogrammer will find them equally distasteful. We might as well pick the one that we find most comfortable - the one from the assembler we are using.
With these decisions made, we can write a concise page of user documentation for the program.
The emit program is used to send a control sequence to a peripheral device from the CP/M command line. Its syntax is
emit device bytes...
which means "Emit these bytes to that device."
The device must be one of the CP/M logical character devices LST:, CON:, or AUX:. The name PUN: may be used for AUX:.
The bytes to be emitted are specified in one of four ways. A numeric byte must begin with a decimal digit. It may be given as a decimal number between 0 and 255; or it may be coded as a hexadecimal number between 0h and 0ffh, with the appended "h" signifying hexadecimal notation.
An ASCII control character may be specified by giving its name. The names of control characters are:
nul soh stx etx eot enq ack bel
bs ht lf vt ff cr so si
dle dc1 dc2 dc3 dc4 nak syn etb
can em sub esc fs gs rs us
The name "xoff" may be substituted for "dc3," and "xon" may be used for "dc1." The name "space" may be used for the ASCII space character and the name "comma" may be used for the comma character.
A literal character, or a sequence of literal characters without any embedded spaces or commas, may be specified by preceding it with either an apostrophe or a double-quote character. An ending quote is optional. Lowercase letters will be converted to uppercase.
For example, this command
A>emit lst: esc, '9, 1bh, ht, 20, "'ello" comma space 'sailor
would emit the following sequence to the CP/M printer:
Long sequences may require multiple command lines. After testing a complicated sequence, you may put the necessary emit commands in a batch command file and execute it with the submit command. Use the stat command (in CP/M 2.2) or the device command (in CP/M Plus) to assign physical devices to the CP/M logical device-names.
That's a very pretty specification; now we have to implement it. We begin, of course, with a draft pseudo-code plan (Figure 4-1).
There are two basic strategies we could use here: we could write each byte to the device as soon as we decode it, or we could decode them all, then write them all at once. The second strategy is shown in Figure 4-1. The first strategy is simpler, but it has the defect that, should the program find a byte it can't decipher, it may have already sent some bytes to the device. Under the second strategy, either all bytes will be sent or none of them will.
emit(device-name,codes)
"dest" is a file
validate the number of command-tokens
validate the device-name, assign to dest, and open
for each code-token c
decide what c's format is
if it's indecipherable, abort with message
translate c to a single byte
save it in an array and count it
end for.
write the saved bytes to dest
end
Figure 4-1. First draft of a plan for emit.
Before we go further with the design of emit, let's turn aside to investigate a general problem that is raised by the first line of Figure 4-1, "validate the number of command-tokens." It describes a step that will be part of nearly every program we write. Every program expects a certain number of command operands, perhaps zero, or exactly two, or sometimes the count of operands must fall in a certain range. Their count is the first thing the program can learn about its operands. What should it do when it gets the wrong number of operands?
The typist program of chapter 3 expected no command operands. The keyfile program expected exactly one, and aborted with an unhelpful message if it was given more or less than one operand. Now we have emit: it wants a device name and some number of bytes to emit. The minimum number of operands it can accept is 1 (when no byte operands are given), while the maximum is the maximum that the user can squeeze onto the line.
What should a program do when the number of command operands doesn't meet its expectations? Some CP/M commands take the approach that if they are given no operands, they report on the system; if they have operands then they change the state of the system. The user command is an example of this policy.
Another policy is to take the absence of operands as a cue to prompt the user to enter the missing operands. In CP/M Plus, the rename and date commands work this way, and the pip command has always followed that policy. We might visualize emit carrying out a dialogue like this one.
A>emit
emit to what device? con:
emit what bytes to CON:? esc "p"
There are things to be said for such a policy; it looks "friendly" as all get-out. Unfortunately, it is difficult to do it well. Once a program undertakes to be interactive, it vastly increases its opportunities for looking stupid. To see what I mean, consider this dialogue.
A>emit
emit to what device? frummage
emit what bytes to FRUMMAGE?
Substitute any conceivable user mistake for "frummage"; the point is, a program has to validate every response as it gets it to avoid looking stupid. But then it must also have the logic to report an error in that response and re-prompt. That goes for every prompted input, and it adds up to a significant amount of programming - often more programming than is involved in the function the program is meant to do in the first place.
Another problem arises from the fact that command operands are positional in nature; that is, the only thing that distinguishes one operand from another is its position in the command line. That makes it impossible to tell for certain which operands have been omitted unless they all are. In the case of emit, for example, if there are any operands, the program must assume that the first one is meant as a device-name. If it gets just one operand, emit can't tell the difference between these two cases:
A>emit lst:
A>emit si
In the first case, the user names a device but omits to say what bytes should be sent to it; in the second, the user has named a byte to send (the byte that puts an Epson MX-80 into condensed mode) but not the device. If the user has been deluded into expecting the program to prompt for a sequence of bytes in the first case and for a device-name in the second, he or she is going to be disappointed; the program doesn't have the information it needs to be that clever.
Finally, we should ask if the effort of prompting for missing operands is really worth all the programming it takes. Its only use is to help the user who doesn't know the usage of the command. The user who knows it wouldn't bother with prompting; it's a lot quicker just to type the operands and be done with it.
If we design our commands to take only a few operands, and if we make those operands flow in a natural sequence, then the only problem we have is how to help the user who has not learned them, or who has temporarily forgotten them. That can be done with a brief usage message - a few lines that summarize the proper use of the program.
Let's establish the rule that, if a program is given no operands, or the wrong number of operands, or if its very first operand is a single question-mark, it will respond by displaying a usage message. Here's how that might look with emit.
A>emit ?
EMIT sends bytes to a device. Syntax is
EMIT device bytes...
Device must be CON: or LST: or AUX: or PUN:
A byte is either
a decimal number from 0 to 255
a hex number from 0H to 0FFH
name of a control character like ESC or CR
quoted literal characters like 'ABC or "9
A>
A display like that is enough to jog the memory of someone who has used the command before but who has forgotten, perhaps, how the program wants hex numbers written. It also summarizes the command for someone who has never used it, but who simply wondered what it was good for. It omits some details, but that's why we write documentation, isn't it?
We could, of course, write the complete program specification into the usage message. I know people who would argue that we should, or rather, who would argue that all systems should have complete documentation of all commands on-line at all times. Those peoples' machines have larger disks and faster processors than mine. Documentation is bulky; and the documentation for a command is used many, many times fewer than the command itself is used.
In every program we do from here on, we will want to save the command operands, check their count, and check the first operand for a question mark. The function is stereotyped and easy to make into a parameterized subroutine. Let's do that, creating a module chkops that we can include in any program. Its pseudo-code appears in Figure 4-2.
chkops(min, max, usage): returns count
min and max are bytes giving the range of operands
usage is the address of a usage message
count is the count of operands
count := savetail()
if (count < min) or
(count > max) or
(first token is "?") then
abort with usage
else
return count
endif
end chkops
Figure 4-2. The plan for the chkops module.
The plan outlines a subroutine that takes three parameters: the minimum and maximum counts of tokens that the program will accept, and the address of a usage message. Since chkops will use the message in an abort macro if it uses it at all, we must be sure that the message ends in a dollar sign.
The plan in Figure 4-2 includes the first use of my personal pseudo-code notation for a subroutine that returns a value. In this case, the returned value is the count of command operand tokens.
The plan is a simple one and not difficult to convert to assembly language. The result is in Figure 4-3. There are a few things worth noting in that listing.
name 'CHKOPS'
; CHKOPS: save command tail, check count of operands and
; check for "?" in first operand. Parameters are:
; reg B = min tokens allowed
; reg C = max tokens allowed
; DE-> usage message ending in $
; returns count of command tokens in reg. A.
maclib environ
dseg
usage dw 0 ; save ->message
cseg
public CHKOPS
entersub
CHKOPS:
push h ; save caller's HL
xchg
shld usage ; save ->message
savetail ; A = count of tokens
push psw ; ..save for exit
cmp b ; count :: min
jc quit ; (less than min)
ora a ; were there zero operands?
jz exit ; (yes, must be right number)
dcr a ; reduce count to force carry
cmp c ; ..on comparison to max
jnc quit ; (count > max)
mvi a,1 ; get DE->first token
tailtokn +A
xchg ; make that HL->token
mov a,m
cpi '?' ; does it start with "?"
jnz exit ; (nope)
inx h
mov a,m ; is it maybe "?xxx" or
ora a ; ..is it exactly ?,null
jnz exit ; (was "?xx...")
quit: lhld usage
xchg ; DE->abort-message
abort ,+D
exit: pop psw ; A = count of tokens
lhld usage ; restore
xchg ; ..caller's DE
pop h ; ..and HL
ret
Figure 4-3. The source text of module Chkops.
The first is the name statement with which it begins. Its only effect is to make the the assembler include that name as one of its notes in the object file. The linker will make no use of the name. Only the lib program notices the name of a module; it keeps a name for each module in an object library. The lib command is easier to use if every module has an explicit name that is the same as its filename.
The code in Figure 4-3 defines only a module, not a complete program. We code the prolog macro as the first statement of a complete program, but that would not be appropriate in a module. That macro generates the code to set up a stack and initialize storage. A module should depend for such matters on the program with which it is linked. However, the prolog macro defines two public labels that a module may need: +BDOS, the gateway to the operating system.
The entersub macro is used to get access to those two names. All it contains is a pair of extern statements to define them as external, but it has a nice, official look to it.
As you read Figure 4-3, you might have noted how the code goes to some trouble to return all registers to its caller just as the caller passed them. The only exception is register A, in which the module returns its result, the count of tokens.
This is a general rule that we will apply to all subroutines: a subroutine will preserve all of its caller's registers, except for the registers in which it returns a result. In chkops we might reasonably suppose that the caller doesn't care if registers B, C, and DE are preserved or not; nevertheless we still preserve them.
It costs us something to apply that rule. It takes time to push and pop registers, and the stack-size requirement goes up. Neither cost is very large. The registers are pushed and popped only once for each call on a module. If the module contains any kind of loop, its real processing time will completely overshadow the time taken to save them. The prolog macro reserves a generous, 256-byte stack when the program starts up. That allows for pushing 128 registers, or for 32 nested subroutine calls each of which saved four registers.
The benefit of applying the rule is that abstract functions keep their abstract nature. When we know that the rule will be applied consistently, then at the moment we code a call to a subroutine we need think only about the subroutine's defined result. We don't have to clutter our minds with exceptions, dangers, special cases. All we have to remember is which registers the subroutine takes as input and which it returns as output. That reduces the number of things we have to keep in mind, and that speeds programming and reduces bugs.
The code in Figure 4-3 illustrates one peculiarity of the Z80 instruction set. The Z80 (and the 8080) have processor flags that are set during a comparison. A comparison is essentially an unsigned subtract; the flags are set as if the instruction operand had been subtracted from the contents of register A.
The processor flags are so defined that, after a comparison, we can detect equality with a jz jump, and we can detect that A was less than the operand with a jc jump. Furthermore, we can detect that A was greater than or equal to the operand with a jnc jump. But there is no single jump that will be taken if A was greater than but not equal to the operand.
That is the condition that must be detected in Figure 4-3 when comparing the count of operands to the contents of register C, the maximum number allowed. There are three ways to handle the situation. The simplest would be to code two jumps in sequence,
cmp c
jz exit ; count = max
jnc quit ; count > max
so that the first jump eliminates the equal-to condition from consideration. Besides being confusing to read, that solution offends my sensibilities; it uses six whole bytes (gad!) to detect a simple condition.
A second solution is to reverse the order of the operands. Instead of comparing to see if count is greater than max, we could arrange things so we could compare to see if max was less than count. It's the same test but its result is revealed unambiguously by the processor flags.
mov b,a ; save count
mov a,c ; pick up max
cmp b ; compare max :: count
jc quit ; max < count, so count > max
That's not easy to read, and it destroys the contents of register B.
A third solution is to reduce the first comparand by one or to increase the second comparand by one. Either strategem merges the case of equality (of the original comparands) with the case of less-than. Thus if we want to test a letter to see if it is less than or equal to "z" we can code the test
cpi 'z'+1
If register A is less than or equal to "z," it will be simply less than a value one more than "z."
In Figure 4-3 we dare not increment the second comparand, since the caller might have passed 255 in register C (meaning, essentially, "I don't care how many"). Incrementing C in that case would cause it to overflow to zero, resulting in a false detection of an error. The method actually used is to reduce the first comparand; if the count less one is less than register C, then the original count must have been less than or equal to C. That trick, also, is subject to error. If the original count was zero, decrementing it would make it underflow to 255. But at that point in Figure 4-3, the case of a zero count has been eliminated.
Now we have taken care of chkops, we can turn to the rest of the plan for emit.
The second item in Figure 4-1 is "validate device name and assign to dest." I first thought to do this with the fassign macro. That macro lets the program know if the token represented a valid, unambiguous filespec. Unfortunately, it does not let the program know if the filespec named a device (that's not usually a consideration when setting up a file).
There is a toolkit macro that inquires whether a file is a disk file or a device, and another that tests to see if a file is read-only or writable. The two in combination would tell the program whether or not it had been given the name of a writable device. Unfortunately, neither of those macros will work until the file has been opened, and we don't want to open the file unless the operand is valid.
There is another way. Part of the toolkit's support for strings is support for searching a table of strings. A table of strings is a collection of string constants. There is a macro, strlook, which accepts the address of a table of strings and the address of single string, and searches for a match to the string in the table. We will build a table of strings, each an acceptable device name, and search it for a match to the first-operand token. If there is a match, the operand is valid.
The rest of Figure 4-1 is concerned with decoding the operands that represent bytes to be emitted, and it is written at very high level (in other words, it's vague). This part is repeated in Figure 4-4.
for each code-token c
decide what c's format is
if it's indecipherable, abort w/ message
translate c to a single byte
save it in an array and count it
end for.
open dest for output
write the saved bytes to dest
Figure 4-4. Plan of the central loop of emit
The way we defined the byte-operands, we can tell the type of each one from its initial character. If it is an alphabetic, the operand must be a name like ESC or COMMA. If a quote, the operand is a literal; if a decimal digit, it's a number - although whether decimal or hexadecimal we can't tell. These realizations, plus some details concerning how the decoded bytes might be stored, are expressed in Figure 4-5.
stuff is an array of bytes
j is a count of the bytes in stuff
j := 0
for each code-token c with initial byte x
if (x is alphabetic) then
j := handle-name(c,j)
else if (x is decimal) then
j := handle-number(c,j)
else if (x is a quote) then
j := handle-literal(c,j)
else badbyte(c)
end for.
open dest for output
write j bytes from stuff to output
Figure 4-5. Expanded plan for the body of emit.
The effect of Figure 4-5 is to organize what we know about initial letters and to defer the decision on what to do about them. Procrastination is an important tool in program design. Here we have decided that there ought to be a separate subroutine for each kind of token, decided what parameters each routine will take, and finally we've put off any decisions on how those routines will do their work. Now each can be considered in isolation.
One other decision is visible in Figure 4-5: there will be a subroutine badbyte to handle the case of an indecipherable operand. It will be given the bad token; it will presumably issue an enlightening error message and abort the program.
Ok, let's tackle those deferred decisions, starting with the case of a token that is initially alphabetic. Such operands ought to name ASCII control characters. They can be decoded with a table of strings. We will set up a table of valid control character names and search in it for the token we got. If it appears, it's a valid operand. If the operand doesn't appear in the table, we will call on badbyte to issue a diagnostic message.
A table of strings can also translate a string. To see how, you must understand how a table is laid out in storage. The heart of a table of strings is an array of addresses. Each address-word points to a constant string. Space for this array is reserved by the strtable macro. Each constant string is assembled by the strentry macro, which also sends the assembler back to fill in the string's address in the array of addresses.
When we apply strlook to search the table, we pass it the address of the array of addresses. The function compares the input string to each table entry in turn, getting their addresses from the array.
Although we will usually define the table-entry strings together, they don't have to be adjacent in storage. Other data can be sandwiched between the strings; it will not affect the way the table is searched. The strlook function gets the strings' addresses from the array; it has no way of telling whether or not they are adjacent, nor does it care.
Used in its simplest form, strlook merely returns the processor's Zero flag true if the input string appears in the table. However, it can be used in such a way that it also returns register HL pointing to the null byte at the end of the table entry that matched the string. That's the key; if we leave the translation of each name following the table-entry that describes it, we will be able to validate a name and translate it in a single step. The plan for names comes out looking like Figure 4-6.
handle-name(c,j) : count
"names" is a table of valid character-names,
each followed by its translation
if strlook(c,names) then
j := j + 1
stuff[j] := translation
return j
else
badbyte(c)
endif
end.
Figure 4-6. A plan for handling names of characters.
The numeric operands are easier to handle. There are toolkit macros that, given the address of a numeric string, will convert it to binary and return the binary result. The only problem is to decide whether the operand was written in decimal or hexadecimal so that the right toolkit function can be applied. That is indicated by its last character, which should be either a digit or the letter "H." If it is neither, or if the conversion routine returns a binary value greater than 255, we will abort through badbyte. The resulting plan is shown in Figure 4-7.
handle-number(c,j): count
"h" is a binary word
if (last byte of c is "H") then
straxbw(c,h)
else
if (last byte of c is a digit) then
stradbw(c,h)
else
badbyte(c)
endif
endif
if (h < 255) then
j := j + 1
stuff[j] := h
return j
else
badbyte(c)
endif
end.
Figure 4-7. A plan for handling numeric operands.
Operands that represent literal characters commence with a quote, either an ASCII apostrophe or a double-quote character. This may be discarded. If the user is a nitpicker and closed the operand with the same character, the closing quote should be eliminated as well. The characters that remain in the string should be counted and copied to the holding array. The only error that I can think of is the case where, after dropping the leading and trailing quote, nothing remains. That should be diagnosed through badbyte. The plan falls out as Figure 4-8.
handle-literal(c,j) : count
"x" is a character
x := initial byte of c
if (x = the last byte of c) then
truncate c to eliminate the closing quote
if (the second byte of c is now a null)
badbyte(c)
endif
endif
for each letter x from the 2nd to the last of c
j := j + 1
stuff[j] := x
end for
return j
end.
Figure 4-8. The plan for handling quoted literals.
In decoding both numeric and literal operands, we need to get at the ending byte of a string. There is a toolkit macro, strend, that will advance a register from the front of a string to the null at its end. From the null, backing up one byte gives the address of the end character. That might, of course, be the first character as well; we'll have to watch out for such one-letter strings.
The emit program comes out quite a bit larger than the ones we've dealt with thus far, so let's examine it in sections, starting with its prologue.
The opening sections of the source of emit appear in Figure 4-9. Like all the programs in this book, the source lines are limited to 64 characters. That's because the assembler adds sixteen bytes of information to the head of each line as it lists it. The longest lines in the listing are limited to 80 characters, and will fit on the screen of the terminal.
title 'EMIT command'
;===============================================================
; EMIT device bytes...
; Emit a control sequence specified by "bytes..." to a logical
; device CON:, LST:, or AUX: as given by "device." Nothing is
; emitted until all operands have been verified. For operand
; formats, see "usage:".
; Abort messages:
; FRUMMAGE is not a device I can emit to.
; FRUMMAGE is not a byte-code I can convert.
; External modules: CHKOPS
; History:
; initial code 18 June 84
;===============================================================
maclib environ
dseg
usage: ; usage message for aborts
cr equ AsciiCR
lf equ AsciiLF
tab equ AsciiTAB
db 'EMIT sends bytes to a device. Syntax is',cr,lf
db tab,'EMIT device bytes...',cr,lf
db 'Device must be CON: or LST: or AUX: or PUN:',cr,lf
db 'A byte is either',cr,lf
db tab,'a decimal number from 0 to 255',cr,lf
db tab,'a hex number from 0H to 0FFH',cr,lf
db tab,'name of a control character like ESC or CR',cr,lf
db tab,'quoted literal characters like ''ABC or "9',cr,lf
db '$' ; must end abort with a dollarsign
abdev: strconst ' is not a device I can emit to.$'
abopr: strconst ' is not a byte-code I can convert.$'
; **keep the next two bytes adjacent and in order**
ntoken db 0 ; number of operands
ctoken db 0 ; token being worked on now
cbyte dw 0 ; ->next byte of "stuff"
stuff: ds 128 ; space for output bytes (and msgs)
dest: filedef 128
cseg
Figure 4-9. The prologue and global data of emit.
The first section is a descriptive prologue. Its purpose is to summarize the main features of the program for another programmer (or for myself at a later time). Professional software shops apply strict, elaborate standards to the layout of such a prologue. We can be more casual, but some things really should be present: a synopsis of the program's function and command syntax, a list of the abort messages it may display, notes on the external modules it requires, and a modification history.
The modification history is especially important. One of the most vexing problems you can face is to find two versions of a program's source, and to be unable to tell which one contains the fix for a certain bug. If you record all fixes and enhancements under the "History" heading in the prologue, you can tell one version of the source from another.
It's my habit to begin the code of a program in the data segment, with the definition of all the constants, files, work areas, and messages. The usage message, if it is first among these, is handy for reference from the prologue.
In Figure 4-9, the usage message is labelled usage; it runs for several lines. Every screen-line of the message must end with a return-linefeed pair, and the message as a whole must end with dollar-sign character. This is because the usage message (and any other message used in an abort) is displayed using the BDOS's type-a-message function, and it always types up to a dollar sign. If the dollar sign is missing, it will type, and type, and type, throwing any sort of garbage to the screen, until finally, somewhere in storage, it finds a random dollar sign.
The variables used in the main body of the program are defined in this section as well. There are three of these in Figure 4-9 - ntoken, ctoken, and cbyte. They are used in the main section of the program.
Every program has a main loop, a top level of the program that determines what things will be done, in what order, and when the program is finished. The source of the main loop of emit appears in Figure 4-10. In a simple program, the main loop might be the only loop, but in emit, much of the processing has been pushed out into subroutines that are called from the main loop.
cseg
prolog
; validate the number of command-tokens
extrn CHKOPS
lxi b,(1*256)+255 ; B=1, C=255
lxi d,usage ; DE->usage message
call CHKOPS
sta ntoken ; save count of operands
; validate the device-name and assign to dest
call checkdev ; do it in a subroutine
; set up for the loop:
mvi a,2 ; first code-token will be
sta ctoken ; ..operand #2
lxi h,stuff ; and will go into
shld cbyte ; ..byte 0 of stuff
mvi b,0 ; count bytes in B (i.e. "j")
; for each code-token c with initial byte x...
for:
lxi h,ntoken
mov a,m ; A = total operands
inx h ; HL->ctoken
cmp m ; total < current?
jc donefor ; (yes, done)
mov a,m ; A = current token
inr m ; ..and ctoken = next
tailtokn +A ; DE->current token
ldax d ; A = initial byte
; if (x is alphabetic) then
; j := handle-name(c,j)
alpha? +A ; is A alphabetic?
jnz notalph ; (no)
call handlename ; yes, handle it
jmp for ; ..and interate
; else if (x is decimal) then
; j := handle-number(c,j)
notalph:
digit? +A ; is it a digit?
jnz notdig ; (no)
call handlenum
jmp for
; else if (x is a quote) then
; j := handle-literal(c,j)
notdig:
cpi '''' ; single quote?
jz isquote
cpi '"' ; ..or double?
jnz notquote
isquote:
call handlelit
jmp for
; else badbyte(c)
notquote:
call badbyte
; end for.
donefor:
mov a,b
ora a ; any bytes decoded?
rz ; (no, quit)
; write the saved bytes to dest
lxi h,stuff
lxi d,dest
outloop:
mov a,m ; A = this byte
inx h ; HL->next one
fputbyte +A ; write it
djnz outloop
ret
Figure 4-10. The code of the body of emit.
The code in Figure 4-10 follows the pseudo-code of Figure 4-5 closely. In fact, I used the lines of Figure 4-5 as a skeleton for it. I concatenated those lines to the first draft of Figure 4-9 and inserted the assembly language code between them, paging back to the global definitions to add variables as needed.
There are some assembly language tricks worth noting in Figure 4-10. The statement
lxi b,(1*256)+255
could have been written just as well as two statements,
mvi b,1
mvi c,255
and might be better so. The effect of the two sequences is identical, but the first is one byte shorter and microscopically faster. One byte is a trivial space and, since this statement is executed precisely once in a run of the program, the time saved is meaningless. The same trick could be justified if it occurred in the heart of a loop that was executed thousands of times.
The bytes ntoken and ctoken are used to regulate the main loop. The first holds the count of command operands, while the second holds the number of the operand that will be processed on the next pass. When the second exceeds the first, processing is complete.
The bytes are compared following the label for. Follow the way that register HL is used to address the pair of bytes. The code sequence
lxi h,ntoken
mov a,m
inx h
cmp m
could as well have been written
lda ntoken
lxi h,ctoken
cmp m
and perhaps ought to have been. Once more we are saving one byte of code and a few microseconds of time. This section of code is executed as many times as there are command operands, so in a typical run it will save less than a hundred microseconds. That is an insignificant optimization, and it is obtained at a steep price. If the ntoken and ctoken variables were not defined adjacently and in that order, the code wouldn't work! That is, incrementing HL from ntoken would not bring it to point at ctoken. Again this is code that can only be justified in a section of a program that will be executed many thousands of times per run. I have a bad habit of making these useless optimizations at inappropriate points; I'll try to do better as we go on.
The program might discover that the first byte operand, operand number two, doesn't exist. In that case, the main loop will end as soon as it starts, and the program will arrive at label donefor with its count of decoded bytes equal to zero. That's fine; every program should "do nothing gracefully"; if this one has nothing to emit, it emits nothing, and ends.
Normally it will have decoded some bytes. The count of them will be in register B - which plays the role of the pseudo-code variable "j" - and their values will be in the array stuff. The body of emit ends with a short loop that picks up each byte and writes it to the file dest.
The line that does the writing,
fputbyte +A
deserves a look. All of the file and string macros in the toolkit allow register notation in this form. The first parameter of a file macro is always the designation of a file definition. In this case, it's the file dest, so the line could have been written
fputbyte dest,+A
However, if it had been, the macro would have generated the code to save the contents of register DE, load it with the address of dest, and restore it after the operation. In Figure 4-10, register DE is loaded with the file's address outside the loop and the +D parameter tells the macro it needn't bother.
The parameter +A is essential, not optional. If we wanted to write a specific byte to the file - the ASCII tab code, for example - we could do it with the line
fputbyte dest,AsciiTAB
specifying a constant value. But in Figure 4-10, the byte value to be written is unknown; it is whatever happens to have been decoded and stored in stuff. That value has been loaded into register A, and the parameter +A tells the macro where to find it.
There are two toolkit macros that will write a single byte to a file: fputchar and fputbyte. As their names suggest, one is specifically for writing ASCII character values, while the other is for writing nondescript bytes of any sort. They make different assumptions about the use of the file that is being written. The fputchar macro assumes a CP/M text file is being created; if we give it a return character, it obligingly writes a linefeed character as well. That way, a program can end a text line with a single operation.
The macro being used here, fputbyte, makes no such assumptions. That is just what emit wants. It is supposed to write exactly and only the bytes that are requested by its command operands.
The skeleton of emit is visible in Figure 4-10, but the program's flesh is in the subroutines it calls. The first of these subroutines, checkdev, is called only once. It is a subroutine only to get it out of sight so as to clarify the flow of the body. Its text appears in Figure 4-11. It contains our first uses of table lookup and of string copying.
; checkdev: subroutine to validate "device" operand and
; abort if it isn't appropriate.
dseg
devtab: strtable 4 ; initiate table of 4 strings
strentry 'CON:' ; fill with valid devices
strentry 'LST:'
strentry 'AUX:'
strentry 'PUN:'
cseg
checkdev:
push h
push d
push psw
tailtokn 1 ; DE->first operand string
strlook +D,devtab ; lookup DE->string in table
jnz baddev ; (didn't find it)
xchg ; make it HL->operand
lxi d,dest ; and DE->file
fassign +H ; assign name to filedef
frewrite +D ; ..and open it
pop psw
pop d
pop h
ret
baddev: ; oops, not a device we know -- build an abort message
xchg ; HL->bad device operand
lxi d,stuff ; place to build message
strcopy +H ; copy "frummage" to stuff
strappnd +D,abdev ; append rest of msg
abort ,+D ; ..and abort with it
Figure 4-11. The checkdev subroutine of emit.
As we decided earlier, checkdev verifies the first command operand by looking it up in a table of valid operands. The table is defined in a short data segment at the head of the subroutine. A string table is defined in two steps. The strtable macro defines space for the array of entry-addresses. Its operand is the number of entries the table will have.
Each entry is defined with the strentry macro. Its parameter, like that of strconst, is a string constant which it defines in storage. But strentry has an additional job: it moves the assembler back to the next slot of the array defined by strtable and assembles there the address of the string constant. Thus a table is defined by a call of strtable followed by a series of calls on strentry.
A table is searched by the executable macro strlook. In Figure 4-11, the program gets the address of the first command operand as a string, and uses strlook to look for that string in the table. If it is not found, the Zero flag is false afterward. Here, if the operand string matches an entry of the table, it specifies a writable CP/M device. The code assigns the string as the name of a file and opens the file for output.
If the operand isn't valid, checkdev must abort the program with an informative message. It does that following label baddev. The informative message will be
FRUMMAGE is not a device I can emit to.
where FRUMMAGE is whatever the operand consisted of. It takes a bit more trouble to echo a bad input within a message, but it makes the message a great deal more helpful, since it is immediately apparent how the program interpreted the operand.
The abort message is constructed from two parts - the incorrect operand and a constant string - and both are strings. The code at baddev builds the message in the stuff array's space, since stuff won't be of any use now. The strcopy function copies a string to a space. Here it is used to copy the operand string to the stuff space.
The constant part of the message is appended to the operand string using strappnd. That macro takes two strings and appends the second to the first, making a single, longer, string. In this case, the second string is the string constant abdev, defined in Figure 4-9. That constant ended in a dollar sign, as is required of any message used in an abort.
It's especially important to repeat the incorrect operand when we report an invalid byte-token (Figure 4-12). Imagine the frustration of a user who types emit with twenty operands, only to be told "Invalid operand." "Which operand, dummy?" he or she would shriek. This is how programmers accumulate bad karma. A programmer who doesn't want to be reincarnated as a cockroach will take care to echo the offending operand as part of the message. It would be even better if emit checked every operand and issued a message for each bad one. It doesn't; it aborts on the first one it can't handle, so the user will have to discover his or her mistakes one at a time.
; badbyte: DE->an operand we can't handle, abort
badbyte:
xchg ; HL->operand
lxi d,stuff ; DE->space to build msg
strcopy +H
strappnd +D,abopr
abort ,+D
end
{QFigure 4-12. The code to abort on any indecipherable
byte operand.Q}
The badbyte code in Figure 4-12 is called as a subroutine, but that's a sham. It always aborts, ending the program, so it can never return to its caller. However, when it aborts, the address of its caller is on the stack. If we were debugging emit with a program like sid, we could set a trap at the entry to badbyte (or at the public label +ERROR where all aborts go to happen). Then we could examine the stack to see where the abort was initiated.
There are three types of operands, and the processing of each has been pushed into a subroutine. The code for handling numeric operands is shown in Figure 4-13.
; handlenum: DE->token commencing in a digit, convert it
; from either decimal or hex.
handlenum:
push psw
push h
push d ; save ->start of string
strend +Dupdate ; DE->null at end of string
dcx d ; DE->last byte of string
ldax d ; A = terminal h?
pop d ; recover ->string
cpi 'H' ; hex number?
jnz nothex
straxbw +D ; convert hex to binary in HL
jmp dechex ; ..and check value
nothex: digit? +A ; not hex, is it decimal?
cnz badbyte
stradbw +D ; convert decimal to binary
dechex: mov a,h ; value > 255?
ora a ; ..H is nonzero if so
cnz badbyte ; (yes, give up)
mov a,l ; no, save value in A
lhld cbyte
mov m,a ; and put in next stuff slot
inx h
shld cbyte ; update pointer,
inr b ; ..and count byte
pop h
pop psw
ret
Figure 4-13. The code for handling numeric operands.
The operand that begins with a digit should end in another digit if it is decimal, or in the letter "H" if it is written in hexadecimal. The code in Figure 4-13 checks this first. It uses the strend macro to advance the string address in register DE until it is pointing at the null byte that ends any string. Decrementing DE yields the address of the byte preceding the null, the last byte of the string.
If that byte is "H," the code invokes function straxbw. This one of a number of toolkit functions for converting between binary values and numeric characters. The names of these macros encode their functions according to this scheme:
The source of the conversion comes first, the destination second, in the macro's name. Thus straxbw converts ASCII hex digits to an unsigned binary word; stradbw converts decimal digits to an unsigned word; strswad converts a signed word into ASCII digits; and so forth. Not all combinations are implemented, since not all are meaningful.
The code in Figure 4-13 uses one of two of these macros to convert the numeric operand to binary in register HL (the destination by default). The result must be tested to see if it can be accomodated in a byte. After all, there's nothing to prevent the user from entering
A>emit lst: 17484
The test is to check the most significant byte of the result; if it is zero, the value fits in a byte.
When it does, the decoded byte must be saved in array stuff and counted. In the pseudo-code, we indicated that with lines like
j := j + 1
stuff[j] := x
That's how we would do it in a high-level language like Pascal. Subscripting an array is more difficult in assembly language. It could be done by loading the address of stuff and adding the count of bytes to it. That comes to only a handful of instructions, which could have been put into a subroutine for use from several places.
What I chose to do (almost without thinking) was to carry the address of the next slot in stuff in a separate variable, cbyte. That address has to be incremented each time a byte is stored, when the count is incremented. You can see it being done at the end of Figure 4-13.
The second type of operand is the quoted literal. The code to handle that type is shown in Figure 4-14. It begins much like the numeric code, finding the last byte of the operand, but it has a different purpose. We decided that the user could, but didn't have to, close a literal with a quote. Here the code is checking to see if there is a closing quote, and obliterating it if so. That shortens the operand string by one byte, and reduces the two cases to one case, that of the unclosed quote.
; handlelit: DE->string starting with one of two quotes,
; copy it (less quotes) to stuff.
handlelit:
push psw
push h
push d
ldax d ; A = opening quote
inx d ; DE->byte after it
push d ; (save that)
strend +Dupdate; DE->terminal null
dcx d ; DE->closing byte of string
xchg ; (make that HL)
cmp m ; did they close the quote?
jnz notclosed
mvi m,0 ; yes, truncate string
notclosed:
pop d ; DE->first literal byte again
ldax d ; is there anything left now,
ora a ; ..or was it just only a quote?
jnz havesome
pop d ; recover ->whole string
call badbyte ; ..and give up
havesome:
lhld cbyte
litloop:
mov m,a ; copy literal byte to stuff
inr b ; ..and count it
inx h ; ..and point to next slot
inx d ; ..and point to next byte
ldax d ; is that byte
ora a ; ..the null at the end?
jnz litloop ; (no, continue)
shld cbyte ; record final ->next slot
pop d
pop h
pop psw
ret
Figure 4-14. The code to handle quoted literals.
Having done this, the code in Figure 4-14 checks to see if anything is left. If there is, all of the one or more bytes must be copied to the stuff array. The process is much like that of storing a numeric byte, but is repeated in a loop.
The final operand type is the named character. These, we planned, were to be decoded using a lookup table. The code that does it appears in Figure 4-15. It is dominated by a large data segment, in which a 36-entry table of names is defined.
; handlename: DE->operand beginning with alphabetic,
; decode it with a table look-up.
dseg
names: strtable 36 ; set up table of names
strentry 'NUL' ; after each name,
db 0 ; put byte value.
strentry 'SOH'
db 1
...etc, etc, until we reach...
strentry 'DC1' ; DC1 = XON = ctrl-Q
db 17
strentry 'XON'
db 17
strentry 'DC2'
db 18
strentry 'DC3' ; DC3 = XOFF = ctrl-S
db 19
strentry 'XOFF'
db 19
...then so on and so forth until...
strentry 'RS'
db 30
strentry 'US'
db 31
strentry 'SPACE'
db 32
strentry 'COMMA'
db 44
cseg
handlename:
push h
push psw
lxi h,names
strlook +Hupdate ; find DE->string in HL->table
jnz badbyte ; give up if not in table
inx h ; HL->byte after matching entry
mov a,m ; ..which is requested value
lhld cbyte
mov m,a ; put in next "stuff" slot
inx h
shld cbyte ; update pointer
inr b ; ..and count byte
pop psw
pop h
ret
Figure 4-15. The code to handle operands that are character-names.
Each table entry is followed by a single byte, the value of that name. The code takes advantage of these bytes when it invokes the strlook with the lines
lxi h,names
strlook +Hupdate
The second operand of strlook is the address of the table to be searched. If it is given as a label, or merely as +H meaning "the address is in HL, use it," the only result of the lookup is to set the Zero flag true or false.
Giving the operand as +Hupdate tells the macro, "the table's address is in HL, use it, but don't save HL for me - give it back the way the lookup code leaves it."
The lookup support module leaves HL pointing to the null at the end of the last table entry it examined in its search. If the search was successful, that entry is the one that matches the search string. And, because of the way the table entries are defined, that address is just one less than the byte that contains the matching value of the name. It can be picked up and saved in stuff.
Building the emit program has exercised our design methods and many of the more interesting toolbox functions. If you are still new to assembly language, emit is a good program on which to practice your code-reading skills. Work through it line by line, referring to a manual or book that explains the use of each instruction, until you feel confident that you understand what the program is doing. In succeeding chapters we will discuss the program code in less detail
In this chapter we will return to the purpose of our first programs: moving data from one place to another. The two programs we'll develop read a disk file and display it on the screen. Although the programs are simple in concept, planning them will lead us into many interesting byways.
The first program is a simple one, almost a finger exercise for programmers, but it has its uses. We want a program that will display the contents of a text file, not as simple text the way the type program would do, but in a way that reveals its exact layout as lines and disk records.
Once in a while we find ourselves with a file of unknown contents, or one produced by a program we don't trust -- maybe a new one we haven't finished debugging. Experience teaches that it's a mistake to just type the file, or pip it to the printer, to see what's in it. It's all too likely to contain strange control characters that will obscure its contents or make the terminal or printer misbehave. Before editing the file, or displaying it as text, we'd like to take a first suspicious peep at it under controlled circumstances.
The purpose of cdump is to provide just those controlled circumstances. It will display a file, but will filter out all control characters so that only printable characters reach the screen. Since we don't know for sure that the file is really a text file, cdump won't assume that it has the text file's structure, lines of moderate length divided by (CR, LF) pairs. Instead, it will display the file in regular units that fit on a screen line. If the file has delimited lines, they will be revealed by a special display of the CR and LF control characters.
In short, cdump displays a file that probably, but not certainly, contains text. It displays all nonprintable characters other than CR and LF as the dot or period character. It displays the CR and LF characters as backslashes (+) as cdump displays it.
The syntax of the command is
cdump filespec
where filespec is the file to be displayed. A typical display looks like this:
.In short, cdump displays a file that probably, but not certai
nly,+contains text. It displays all ASCII control characters
as the dot or period+except for the CR and LF characters, wh
et cetera...
Each line displays exactly 64 characters from the file. This length may not match the length of text as it would be displayed by, for instance, the type command, since cdump displays a TAB character as a dot while type will display it as from one to eight spaces. The format is, however, an aid to programmers, since each pair of records corresponds to a CP/M file record of 128 bytes.
The program displays all the file up to physical end of file, so some of the characters in the last two display lines may be garbage that follows the logical end of file.
That functional specification tells us several things. First, since the entire file is to be displayed right through physical end of file, and since the CR and LF characters are to be treated individually, we know that we are not going to treat the file as a text file; we will read it with toolkit macros for binary files.
That simplifies the program design a bit. All CP/M files are stored in multiples of 128 bytes. Text files, however, can contain a logical end-of-file character (it's the SUB control character, also known as 1Ah, 26 decimal, and Control-Z) which might turn up end of file at any point. In a binary file all bytes are data, so end of file can only happen after all bytes are used, which will happen on a byte that is a multiple of 128.
These considerations lead us to the plan shown in Figure 5-1. It expresses the idea that we can make two lines -- twice 64, or 128 bytes -- before we have to check for end of file. But it could use some elaboration. Displaying a line is no problem; fputline user,line will do it nicely. But preparing a line for display is the heart of the program, and the problem of discovering end of file needs consideration.
program cdump(filespec)
input is a file
user is a file representing the screen
line is space for a 64-byte string
check operand count of 1, etc. (CHKOPS)
validate filespec, assign it to input, and open it
repeat
make first 64-byte line and display it
make second line and display it
until end-of-file(input)
end cdump.
Figure 5-1. First cut at a plan for cdump.
A plan for displaying a line is shown in Figure 5-2. It expresses the idea that a line is formed by reading and translating exactly 64 bytes from the file, and appending the printable characters to the display line.
translate(x) : character
if (x > 127) then x := x - 128
if (x >= space) and (x < DEL) then return x
if (x = CR) or (x = LF) then return backslash
return dot
end translate.
makeline()
x is a character
make line a null string
do 64 times
x := fgetbyte(input)
x := translate(x)
append x to line
end for
end makeline.
Figure 5-2. Plan for preparing a line for display by cdump.
The only characters that need translation are the control characters. They are easily identified; their byte values are less than that of the space character. Well, perhaps it isn't quite that easy! The DEL character, byte value 127, is also a control character. It has no printable representation, so it, too, should be translated.
Furthermore, certain word processors have the unfortunate habit of sprinkling text files with what should be ordinary ASCII characters, but which have their high bits set. What should cdump do with these? It could treat them as control characters, displaying them as dots. That's reasonable, since a byte with a value greater than DEL's 127 has no printable representation. (No standard representation, anyway. Lots of devices will print or display different symbols when sent bytes greater than 127, but no two agree on what symbols to use for what bytes.) On the other hand, the user might get more information about the file if such characters were reduced to ordinary ASCII and then translated. That would reveal more meaning in some files, at the cost, in others, of displaying characters where no character was intended. The plan in Figure 5-2 adopts this latter course.
One line of Figure 5-1 needs attention. It says, "validate filespec, assign it to input, and open it." There are several steps that have to be taken to do this. And from here on, most of our programs will have to read from a file named by a command operand, so this is going to be a problem we will face again and again. It's a good application for a separate module that we can use over and over.
What does it take to prepare an input file for use? The first step is to assign the command operand string to a file definition with the fassign macro. Its return tells us whether or not the operand is a valid filespec. If the operand was invalid the program should abort, but the ending message should echo the invalid operand.
The operand might be valid by the generous rules of CP/M, but it might still be ambiguous; that is, it might contain a question mark or asterisk so that it refers to more than one file. Such a filespec is of no use for input. Under CP/M Plus, an attempt to open such a file is illegal and will terminate the program with a CP/M error message. Under CP/M 2.2, we can open an ambiguously-named file, but we can't predict what file we will end up reading; it will be the first file in the disk directory that happened to match the name.
The fassign macro reports whether the filespec was ambiguous. If it does the program should abort with an informative message -- again, a message that echos the operand the way the user typed it.
When neither of these conditions interferes, the file may be opened for input with the freset macro. The code that supports this macro reports back whether or not there is any data to be had from the file. There is data to be had if the file is a disk file that has data in it, or if it is a device that is capable of input. The file can produce no data if it doesn't exist at all; or if it is a device that can't do input, such as PUN:; or if it is a disk file with no data in it.
All of these last three conditions are lumped into the single flag returned by freset. In most cases, they all mean the same thing: that there is nothing for the program to do. The usual response to such an empty-file report is to terminate, once more with an message. However in this case the message has to be worded more vaguely.
It doesn't take a great deal of code to perform these steps, but it it's better to do it once for all and put the results in a separate module, one we might call openin. The code for the module appears in Figure 5-3.
name 'OPENIN'
;
; OPENIN(operand-number,filedef) -- assign the specified command
; operand to the filedef and open it. Abort with one of three
; messages if problems arise:
;
; FRUMMAGE is not a valid filespec.
; FRUMMAGE is ambiguous; an explicit name is needed.
; FRUMMAGE is empty or does not exist.
;
; A = operand number to assign
; DE->filedef to use
;
maclib environ
dseg
is: strconst ' is '
inval: strconst 'not a valid filespec'
ambig: strconst 'ambiguous; an explicit name is needed'
empty: strconst 'empty or does not exist'
dotdol: strconst '.$'
msg: strspace 64
cseg
entersub
public OPENIN
OPENIN:
push psw
push b
push d
push h
xchg ; save ->file in HL
tailtokn +A ; DE->operand
xchg ; ok, HL->operand, DE->file
fassign +H
lxi b,inval ; (set up for invalid)
jz error ; oops, not a valid filespec
cpi '?' ; was it ambiguous?
lxi b,ambig ; (prepare in case it was)
jz error ; (yes)
freset +D ; try to open it
lxi b,empty ; (but assume we can't)
jz error ; (we couldn't)
pop h ; could. all done.
pop d
pop b
pop psw
ret
; Something went wrong, and BC->the details of what.
; HL still points to command token. Build an abort
; message and quit.
error:
; copy the operand to the message field first
lxi d,msg
strcopy +Hupdate
; add the word " is "
lxi h,is
strappnd +Hupdate
; then add the specific explanation
mov h,b ! mov l,c
strappnd +Hupdate
; finish up with a period and a dollar sign
lxi h,dotdol
strappnd +Hupdate
; that does it -- byebye
lxi d,msg
abort ,+D
end
Figure 5-3. The source code of module openin.
The code in Figure 5-3 demonstrates two new things. The first is a common trick, that of anticipating a possible outcome in order to avoid writing extra jump instructions. Consider the lines that go
fassign +H
lxi b,inval
jnz error
The point of them is that, if fassign reports an error, we are to transfer to error with the address of message inval in register BC. The lines could have been written this way,
fassign +H
jz ok1
lxi b,inval
jmp error
ok1:
with the business of loading the address of the error message being deferred until we know that it is needed. That is just how most compilers would code the equivalent "if" statement. But the second version requires us to invent a label to name the good case, and to write two jumps. We avoid that by loading BC in anticipation of having to jump, then jumping only if necessary. The trick is used in two other places.
The second thing is the construction of a string from pieces. The abort message is built up from four parts -- the operand string, a constant, whatever string is addressed by register BC, and another constant. This requires four macro calls. The register operand +Dupdate operand says, "DE has the address, and don't save it -- let the function update it for me."
The string functions usually update the destination pointer so that it points to the null byte at the end of the string. The strappnd operation has to advance to the end of the destination string before it can begin appending to it. By allowing the string address in DE to be updated each time, we keep it pointing at the current end-byte of the string, reducing the amount of work the support functions have to do. This gains a meaningless savings of time -- meaningless because the speed with which a program goes to abnormal termination doesn't matter a bit!
Thus far we have planned cdump as if the input file would go on forever. That's the easy way to think about it. Things proceed in a nice, regular way as we go along through the file, and the plans reflect that regularity.
End of file is a discontinuity, an abrupt break. The program must stop what it has been doing and do something else (in this case, terminate). We simplified things by having the openin module abort if the file was empty. That eliminated the case in which end-of-file turns up immediately; since CP/M stores files in units of 128 bytes, we can be confident that there will be at least enough data for the first pair of lines. However, once that pair has been processed, we have to find out if there is data to make the next pair.
Programming languages vary in the way they present end of file. Some treat it as a surprise, an error condition that might crop up during an attempt at input, and supply a mechanism for jumping elsewhere in the program when it arises. PL/I and COBOL take this approach. In those languages, you write code that says, "I don't believe end of file will happen, but if it does, go there."
Other languages treat end of file as an expected condition or state of a file. These languages provide a means of checking for the end-of-file condition before a read is attempted. Pascal is one such language; in it you are required to write the equivalent of "Is end of file true? If not, read."
The toolkit functions that read files do not anticipate end of file. If there is another byte in the file, fgetbyte will return it. Nothing special will happen even if that is the very last byte in the file. Only when it finds that no more data is to be had will an input function report end of file by returning the Zero flag true. This approach made the toolkit easier to write and more efficient in execution (and in fact no other approach is possible with files that are really devices), but it causes problems for a plan like the one in Figure 5-1 and Figure 5-2.
In Figure 5-1, we are assuming that we can test for end of file after reading the 128th byte and before trying to read the 129th byte. This is Pascal-style programming; it assumes that there is a way to look ahead and see the end of file looming without actually attempting a read. Fortunately, there is; but imagine the consequences if there wasn't. If the only way to detect end of file was to attempt a read, then our whole plan would have to be turned inside out. The makeline routine (Figure 5-2) would detect end of file when it tried to read a byte; it would have to end early and return a signal; then the main loop (Figure 5-1) would have to notice the signal and break off its loop early. As an exercise, work out a plan that would work under these conditions. There are a number of dodges you can work, but the result will always be more convoluted than what we have now.
The feof? macro (the Digital Research assemblers permit question-marks in macro names) will test for impending end of file. It reports back with the Zero flag true if the next input request will fail because of physical end of file; that is, if there are no more bytes to be had at all. This function gives us just what we need to implement the "until" test in Figure 5-1.
The feof? function can be used with text files as well as binary ones. When the file is not at its physical end, it returns a copy of the next byte in register A so that the program can detect an impending logical end of file -- the control-Z character.
This function can only work correctly only on disk files. Think about it: what meaning could "end of file" have when applied to a device like CON:? On one hand, a device is always at end of file, since there is no next character ready until the device produces one, and it might not. On the other hand, it is never at end of file because it is capable of producing another byte. This is one area in which disk files and devices can't be treated alike. The feof? macro always reports end of file on device files, while input functions never do (they just wait for a byte to arrive).
There is yet another factor that our cdump plan ought to take into account (gee, it looked like such a simple program!). As the plan stands, cdump is going to finish the display of any file it starts on. Suppose the user sets it going on a large file, and then decides that the display is useless. Maybe the first few lines of the display answer the user's question, or maybe the wrong file was named. Whatever the reason, the program has started and now the user wants to cancel it and get on to something else.
The usual way to cancel a CP/M program is to press control-C, but that is only effective when the system is paying attention to the keyboard. The BDOS only monitors the keyboard for control-C during an input request (cdump makes none) or while the display is frozen by control-S. Our user could cancel cdump by pressing control-S and then control-C. But the user can cancel system commands like type and dir by pressing control-C alone, and a display program like cdump should be just as accomodating, if only for consistency.
We can incorporate this need in the plan of Figure 5-1 by adding another "until" condition:
repeat
make first 64-byte line and display it
make second line and display it
until end-of-file(input) or keyboard-pending
There is a toolkit macro, testcon, which tests for pending keyboard input, much as the BASIC function INKEY$ does. It will allow us to implement the additional test.
Once all the planning has been done and the openin module has been coded, the rest of cdump is easy to implement. Its code appears in Figure 5-4.
;===============================================================
; CDUMP filespec
; Displays file "filespec" in ascii, 64 bytes to the line, for
; safe inspection when the contents of the file are in doubt.
; Abort messages:
; usage message
; input-open messages
; Modules:
; CHKOPS
; OPENIN
; History
; initial code 6/21/84
;===============================================================
maclib environ
dseg
usage: db 'CDUMP filespec'
db AsciiCR,AsciiLF
db 'Displays contents of file 64 bytes/line, with'
db AsciiCR,AsciiLF
db 'control characters translated to "." and ends'
db AsciiCR,AsciiLF
db 'of lines shown as "+".'
db AsciiCR,AsciiLF
db '$'
user: msgfile
input: filedef 1024
line: strspace 64
cseg
prolog
; check operand count of 1, etc (CHKOPS)
mvi b,1 ; minimum operands: 1
mov c,b ; maximum ditto
lxi d,usage
extrn CHKOPS
call CHKOPS
; validate filespec, assign it to input, and open it
mvi a,1 ; operand 1 is filespec
lxi d,input ; filedef to assign it to
extrn OPENIN
call OPENIN
; repeat
loop:
; make first 64-byte line and display it
call makeline
fputline user,line
; make second line and display it
call makeline
fputline user,line
; until end-of-file(input) or keyboard pending
feof? input
jz done
testcon
jz loop
; end cdump.
done: ret
; makeline()
makeline:
push psw
push b
push d
push h
; make line a null string
lxi d,line
strnull +D
xchg ; carry HL->string position
lxi d,input ; ..and DE->file
; for t := 0 to 63
mvi b,64 ; set loop count
mloop:
; c := fgetbyte(input)
fgetbyte +D
; c := translate(c)
call translate
; append c to line
xchg
strput +A
xchg
; end for
djnz mloop
;end makeline.
pop h
pop d
pop b
pop psw
ret
;translate(x) : character
translate:
; if (x > 127) then x := x - 128
cpi AsciiDEL+1
jc lt128
ani AsciiDEL
lt128:
; if (x >= space) and (x < DEL) then return x
cpi AsciiBlank ; if less than space
jc iscontrol ; ..it's a control char
cpi AsciiDEL ; not less than space,
rc ; if < DEL, is ascii
iscontrol:
; if (x = CR) or (x = LF) then return backslash
cpi AsciiCR
jz slish
cpi AsciiLF
jnz dot
slish: mvi a,'+'
ret
; else return a dot
dot: mvi a,'.'
ret
;end translate.
end
Figure 5-4. Source code of the cdump program.
As will often be the case, the assembly source follows the pseudo-code plan very closely; so closely, in fact, that it was created by editing the pseudo-code file and filling in the necessary assembly statements between each line. (I don't mean to say that they were filled in quickly or thoughtlessly! I worked back and forth over the file many times, reading and revising. But the pseudo-code lines supplied the skeleton.)
Observe the use of registers HL and DE in the subroutine makeline. This routine has two objects to keep track of: the file input and the developing display string in line. It takes bytes from the file with fgetbyte, which returns the next byte in register A, and it appends translated bytes to the string with strput.
Both of these macros could have been given the names of their objects as parameters, as in
fgetbyte input
strput line,+A
But the code of makeline goes to the trouble of carrying the addresses of file and string in registers. Having set up the registers, it could have appended it to the string with
xchg
strput +A
xchg
That would have saved the macro's generation of code to load the string's address in register DE. But what the code uses is the operand +Dupdate operand causes the macro to not preserve the starting value of DE, leaving it instead as the support function returned it. Thus the register traces the end of the line as it develops, saving 64 scans from the front of the line to its end in the course of the loop. This time the extra complexity really does save some time.
The cdump command is useful for checking the contents of files which we expect to contain only text. But there are times when we want to look into files that aren't supposed to contain text, files that we expect will consist of binary data. Cdump doesn't tell us enough about such files; they are full of binary values that it renders only as dots.
We need a binary-dump program, bdump, which will show us the precise contents of a file in all their hexadecimal glory. Binary files don't have any internal markers to divide them into convenient sections, as text files are divided into lines, so bdump may as well divide them into the 128-byte chunks that CP/M calls "records." These don't usually correspond to the records of the file as they are defined by the programs that create or use such a file, but they are the only divisions that are common to all binary files.
The bdump program, then, will display a file in 128-byte groups, each group identified by its relative record number -- the number of the record within the file, counting from zero. The contents of the file will be shown in hexadecimal and in ASCII.
There is more than one way to display data in hexadecimal. The most common way is to spread it out on a line, with a space between each byte and with the ASCII values following:
30 31 32 33 34 35 36 37 38 39 3A 3B 3C 3D 3E 3F 0123456789:;<=>?
40 41 42... +AB...
That format uses one line to display just 16 bytes of data. Another way is to invert the format, showing the hexadecimal digits in columns below their ASCII translation:
0123456789:;<=>?+AB...
3333333333333333444...
0123456789ABCDEF012...
This vertical format allows us to put 64 bytes across the screen. However, it takes three lines to show those bytes, and requires a blank line between each 64-byte display to avoid confusion. The final result is the same; both formats use up eight screen lines in displaying 128 bytes of data. The horizontal format is slightly easier to create using the toolkit macros, so we'll use it.
Bdump is much like cdump in its outline. It must open a file, read it, and display it. A draft plan expressing this appears in Figure 5-5.
program bdump(filespec)
input is a file
user is a file representing the screen
check operand count of 1, etc. (CHKOPS)
validate filespec, assign and open (OPENIN)
repeat
get 128 bytes and display it
until end-of-file(input) or keyboard-pending
end bdump.
Figure 5-5. A broad-brush plan for bdump.
That plan merely repeats the obvious, but there is virtue in writing it down anyway. I have often made the mistake of wading into the details of a program I thought would be just like some other, only to discover that it wasn't; that something in the specification mandated a different plan. Now that we have examined Figure 5-5 and decided that, yes, it is just like cdump's plan and so should present no problems, we can set it aside. What we have accomplished is to eliminate all of the problem except the line "get 128 bytes and display it," and now we can focus on that.
It's no problem to get 128 (or any other number) of binary bytes from a file; there is a toolkit macro, fgetblok, for just that purpose. Having gotten the data, we must display it in eight lines with a record number at the end of the first one. That leads to the plan in Figure 5-6.
show128(recnum)
block is a space for 128 bytes
line is space for a display line
fgetblok(input,block)
process 1st 16 bytes into line
append record number recnum to line
fputline(user,line)
do 7 more times
process next 16 bytes into line
fputline(user,line)
end do.
end show128.
Figure 5-6. A plan for displaying one 128-byte block.
The first line has to be treated differently because only it will contain the record number. The remaining lines can be handled in a loop. However, the parallel construction of "process 1st 16 bytes" and "process next 16 bytes" is no accident. Most of the first line will be created by the same actions that create the whole of the other lines, and those actions can be a subroutine. Thus we reduce the problem "get 128 bytes and display it" to one of "process 16 bytes into line."
Each display line has two parts, a display of hex numbers and a display of ASCII text. The hex numbers will reflect the exact values of the bytes from the file, while the text display will show them in the way that cdump does -- with characters for the printable bytes and dots for the ones that aren't.
I can think of two ways of building such a display. The first way is to build a single display line by going over the file data twice. This method is outlined in Figure 5-7.
show16(bytes)
bytes is 16 bytes of file data in storage
make line the null string
for each byte x in bytes
append hex display of x to line
append a blank to line
end for
append another blank to line
for each byte x in bytes
append translate(x) to line
end for
end show16.
Figure 5-7. One of two ways of building a display line.
The second way is to process each file byte once, putting it into each of two strings, and then to concatenate the strings. This method is outlined in Figure 5-8. In both figures, the notation "translate(x)" refers to the same subroutine that we used in cdump.
show16(bytes) 2
bytes are 16 bytes of file data in storage
ascline is space for a 16-byte string
for each byte x in bytes
append hex display of x to line
append translate(x) to ascline
end for.
append a blank to line
append ascline to line
end show16.
Figure 5-8. An alternate way to build a display line.
In this program, it doesn't much matter which of the methods we use. In a higher language like C or Pascal, we'd probably favor the second plan because it has one less for-loop. When I look ahead to the assembly language encoding of each plan, however, I can see that in the first plan there is one less thing to keep track of at any one time. That is, in the first plan the program will only have to keep track of its position in the file bytes and in one string, while in the second it has two string-addresses to juggle.
The assembly code of bdump appears in Figure 5-9. As usual, it follows the pseudo-code plan. However, I made a change as I wrote it. The structure of bdump is nested deeply enough to be confusing. The structure could be summarized this way.
bdump calls on
chkops and
openin and
show128 which calls on
show16 which calls on
translate
In such a structure, it ought to be possible to understand any one level independent of the others. That's the only way to keep the program understandable and maintainable. It is always necessary to understand the abstract function of the lower routines that are called, but that is the only interlevel information that should be required. It's an important principle of software design that if you can't read a routine at one level without referring to the code in levels above or below it, the structure of the program is faulty.
title 'BDUMP -- binary file display'
;===============================================================
; BDUMP filespec
; Displays file "filespec" in hex and ascii, 16 bytes to the
; line, with CP/M record numbers.
; Abort messages:
; usage message
; input-open messages
; Modules:
; CHKOPS
; OPENIN
; History
; initial code 6/26/84
;===============================================================
maclib environ
dseg
usage: db 'BDUMP filespec'
db AsciiCR,AsciiLF
db 'Displays contents of file 16 bytes/line, with'
db AsciiCR,AsciiLF
db 'data shown in both hex and ascii, with CP/M'
db AsciiCR,AsciiLF
db 'file record numbers.'
db AsciiCR,AsciiLF
db '$'
input: filedef 1024
cseg
prolog
; check operand count of 1, etc (CHKOPS)
mvi b,1 ; minimum operands: 1
mov c,b ; maximum ditto
lxi d,usage
extrn CHKOPS
call CHKOPS
; validate filespec, assign and open (OPENIN)
mvi a,1 ; operand #1 is filespec
lxi d,input ; DE->filedef for it
extrn OPENIN
call OPENIN
; HL will carry record number
lxi h,0
; DE will address input file
lxi d,input
; repeat
; get 128 bytes and display it
loop128:
call show128 ; with(file,recnum)
inx h ; next record number
; until end-of-file(input) or keyboard-pending
testcon ; keyboard pending?
jnz done ; (yes)
feof? input ; no, end of file?
jnz loop128 ; (no, continue)
;end bdump.
done: ret
;show128(DE->file,HL=recnum)
; user is a file representing the screen
; line is space for a display line
; block is space for 128 bytes of data
dseg
user: msgfile ; moved to this level
line: strspace 80
block: ds 128
cseg
show128:
push psw
push b
push d
push h ; save recnum on top of stack
; fgetblok(given file,block)
fgetblok +D,block,128
; process 1st 16 bytes into line
lxi h,block
lxi d,line
call show16 ; with(line,bytes)
; append record number recnum to line
strput +Dupdate,AsciiBlank
xthl ; save ->bytes, HL=recnum
strbwax +H
xthl ; get back HL->bytes
; fputline(user,line)
fputline user,line
; do 7 more times
mvi b,7 ; loop count for djnz
loop7:
; process next 16 bytes into line
lxi d,16
dad d ; advance HL to next 16
lxi d,line
call show16 ; with(line,bytes)
; fputline(user,line)
fputline user,line
; end do.
djnz loop7
;end show128.
pop h
pop d
pop b
pop psw
ret
;show16(DE->line,HL->bytes)
show16:
push psw
push b
push d
push h ; HL->bytes to display
; make line the null string
strnull +D
; for each byte x in bytes
mvi b,16
loophex:
; append hex display of x to line
mov a,m ; get byte "x"
inx h ; ..and advance pointer
strbbax +A
; append a blank to line
mvi a,AsciiBlank
strput +A
; end for
djnz loophex
; append another blank to line
strput +A ; A = blank at end of loop
; for each byte x in bytes
pop h ; recover ->bytes
push h ; ..and save again
mvi b,16
loopasc:
; append translate(x) to line
mov a,m
inx h
call translate
strput +A
; end for
djnz loopasc
;end show16.
pop h
pop d
pop b
pop psw
ret
;translate(x) : character (from cdump)
translate:
; if (x > 127) then x := x - 128
cpi AsciiDEL+1
jc lt128
ani AsciiDEL
lt128:
; if (x >= space) and (x < DEL) then return x
cpi AsciiBlank ; if less than space
jc iscontrol ; ..it's a control char
cpi AsciiDEL ; not less than space,
rc ; if < DEL, is ascii
iscontrol:
; if (x = CR) or (x = LF) then return backslash
cpi AsciiCR
jz slish
cpi AsciiLF
jnz dot
slish: mvi a,'+'
ret
; return a dot
dot: mvi a,'.'
ret
;end translate.
end
Figure 5-9. The assembly source code of bdump.
The levels of a program are most independent when the only flow of data between levels is through subroutine arguments downward and subroutine results upward. Any other method of exchanging data between levels confuses the relationship between levels, and can cause a change in the code at one level to propogate up or down to another level. There are several kinds of inter-level relationship that can confuse a program's structure, and some of them are very subtle. But the worst is the sharing of named variables.
Until now we have been casual about this principle, but the structure of bdump is elaborate enough that we should pay attention to it. There are two key tests for level independence. The first is, "Could this subroutine be completely rewritten without causing a change in any program that calls it?" The second is the reciprocal query, "Could the program that calls this subroutine be completely rewritten without causing a change in the subroutine?" The chkops and openin modules satisfy both; they depend only on the arguments passed to them in registers. So does the translate subroutine from cdump.
The show128 routine planned in Figure 5-6 and the show16 routines in Figure 5-7 and Figure 5-8 do not. Each depends on a variable that is defined at a higher level. Show128 assumes the existence of a file named input; show16 relies on a string-space named line. In both cases, the second test fails -- the calling program couldn't be completely rewritten without changing at least the names used in those subroutines.
Is this merely a philosophical quibble? In a program as small as bdump, perhaps so. But no; the show128 and show16 routines are potentially useful in other programs. Someday we might want to extract them, make modules of them, and use them with some other program. We would not be able to because of those cross-level name references. They would have to become external names in a module; if the code were copied bodily into another program, that program would have to define a file and string-space with the identical names and use them in the identical way. Or suppose you worked in a group of programmers and wanted to make these routines available to the others: imagine what the documentation for these little modules would look like. (In fact, over-complicated technical documentation is as often the fault of poor structure in the thing documented as it is the fault of the writer.)
It's usually easy to fix a modularity problem at the design stages. I fixed the problems in show128 and show16 as I wrote the assembly code. I made sure that all the resources they needed were either private to them, or handed to them in registers. It didn't cost anything in program size or speed.
We've designed and coded two simple file-display programs, but they turned out not to be quite as simple as expected. Designing them led us to consider how to: open an input file named as a command operand, detect end of file, and cancel a program when the user tires of it. And even a small program proved to have a structure complex enough to motivate some attention to proper modular design. In the next chapter we'll apply these principles to a more complicated display task
In this chapter we develop a pair of utilities; that is, programs that read an input file and write an output file containing the input data, modified. Along the way we will discuss the design of utilities in general, and develop another useful module.
These particular utilities tackle the problem of inserting and removing tabs from a file of ASCII text. The problem is interesting and easy to state. Because of that, it has become a standard exercise for systems programmers.
CP/M has a convention for the use of tab characters. Every serial I/O device is assumed to have tab stops set at every eighth column. If columns were numbered from zero, the stops would be at columns 8, 16, 24, and so on. It is conventional to number columns from one, so the usual way of stating the tab convention is to say, "tab stops are assumed in columns 9, 17, 25, etc."
Because of that assumption every tab has a consistent interpretation, no matter where in a file it appears: it always stands for the number of spaces needed to move to the next eighth column.
The CP/M tab convention has the useful effect of providing automatic data compression. Some operating systems do not have a tab convention. In those systems, a tab has no consistent meaning. As a result, tabs almost never appear in text files -- long strings of blanks appear instead. Multiple blanks almost never appear in files created under CP/M; they are replaced by short strings of tab characters.
Of course, not all programs put tabs into their output files. Some should not! If the data in a record occupies a different column than it will in its eventual output, tabs should not appear in it. Let's look at an example. Imagine a BASIC program like this,
3000 FOR I=1 TO J
3010 WRITE#1,DESC$(I),PRICE(I)
3020 NEXT I
which prepares an output file like this:
" Midget Widget",0.45
" Widget",0.85
" Jumbo Widget",1.49
In other words, some records contain leading blanks. The programmer has put them there for some good reason, and they should not be replaced with tabs. The fields of these records bear no necessary relation to the columns in which they begin. They might, for example, become input to a program like this,
7200 FOR I=1 TO J
7210 READ#1,WHAT$,COST
7220 PRINT "Description: ";WHAT$,COST
7230 NEXT I
which will not produce the expected output if there are tabs in the input. The tab that made sense in column 1 of an input record will not be correct in column 14 of a print line. If the data file was edited, and if the editor program replaced blanks in its output with tabs, the second program would produce the wrong output.
On the other hand, some programs produce files that don't contain tabs when tabs would be all right. Files brought to CP/M from some other operating system often don't contain tabs. Worse, files from some other operating system might contain tabs that are based on some other tab increment than eight.
If we had a utility that compressed a file by replacing blanks with tabs wherever possible, and another one that replaced tabs with the spaces they stood for, then we could solve any of these problems. We would to be able to take a file without tabs and tabbit, or take a file containing tabs based on any regular increment and untab it.
Before going further with the design of these programs, let's turn aside to the general problem of how a command like "take that file and tabbit" ought to be phrased.
How shall we phrase a command like "untab that file"? Utilities are common programs, and each of them implements a similar command: "modify this file"; or more often, "modify this file and put the result in that one." It would be a good idea to have a consistent style for such commands.
One syntax that seems very natural to me is this one:
A>utility infile outfile
where infile is the file that utility is to read, and outfile is the file it is to write. Sometimes we shall find need for a third operand, options:
A>utility infile outfile options
Such a command seems to me to say, "perform utility on infile, put the result in outfile, and by the way, do it according to options."
Our work would be easier if we could require the user to specify complete filespecs for both infile and outfile. But good utility programs are used often, and the commands are much easier to type if we allow a reasonable set of defaults for the file references.
In CP/M 2.2, a file reference consists of three parts -- a drivecode, a filename, and a filetype. We can represent them so:
x:filename.typ
In CP/M Plus, as in all the newer Digital Research operating systems, a filespec has a fourth part, its password:
x:filename.typ;password
All three (or four) parts are set up in a file definition by the fassign toolkit function, and its input is usually a command operand string typed by the user. The user, however, ought not to be made to give all the parts of a filespec operand when the program can provide a reasonable default. The user will appreciate us even more if we use the default rules consistently in every utility.
If the user omits the drivecode, fassign will set up the file for access to the default disk drive, the one whose letter appears in the system prompt. This is standard CP/M practice and we will apply it to the input operand.
CP/M allows a file to have a filename that is completely blank, as in "b:.doc." Some programs won't accept such a filename, and people hardly every create one except by accident, but just the same such filespecs are permissible. If the infile operand omits the filename, it specifies a filename of eight spaces.
A filetype of three blanks is allowed by CP/M, as in "b:letter" or "b:letter;pass." Again, such files are rarely created, but they are permissible and we will permit them. When the filetype is omitted from the infile operand, we will assume a filetype of three spaces.
All of these things are done automatically by fassign when it processes a character string into a file definition.
The outfile operand is a file reference and has the same parts. If a part of the output fileref is omitted, what shall a utility do? The simple answer would be "exactly what it does for the infile operand." However, a utility's output file is closely related to its input file. It would be convenient if its default file reference were related as well. A simple rule is this: if a part of the output filespec is omitted, a utility will use the same part of the input reference. Let's consider the effect of that rule for several variations of a hypothetical utility program, process.
A>process chap01.doc b:
The input file is a:chap01.doc (the default drive will be used). The output drivecode has been specified, but the output filename and filetype have been omitted. Copying these parts of the input fileref gives a complete output file reference of b:chap01.doc. That certainly seems more reasonable than sending the output to a file whose name and type are all-blank.
A>process chap01.doc .dat
The input file is again a:chap01.doc. The output filetype is given but its drivecode and filename are missing. Taking them from the input reference gives an output file of a:chap01.dat. The user has been relieved of making six redundant keystrokes.
A>process input c:output
The input file is a:input (default drive, blank filetype). After copying the (blank) filetype from the input operand, the output file is c:output.
A>process b:replaced.doc
The input file is b:replaced.doc. All three parts of the output reference are missing. Copying them from the input reference gives us an output file of -- b:replaced.doc. This scheme of defaults calls for the output file to replace the input file! Can we permit this? It seems to imply that the program will be writing to the same file it is reading from.
When I realized this, my first thought was to find some way to ban it. On reflection I realized, first, that the situation could arise no matter what rules we use (all the user has to do is type the same filespec in full for both operands) and second, that the facility is a useful one; there are many times when exactly what we want a utility to do is to replace a file with a modified version of it. It turns out to be perfectly feasible to implement.
We have specified what a utility should do with the drivecode, filename, and filetype of its input and output operands. What should it do with their passwords? Under CP/M 2.2, or course, the answer is "nothing." The fassign macro will process a password and store it where CP/M can find it, but the BDOS of CP/M 2.2 will ignore it. Under CP/M Plus, a utility has to do something reasonable when one or both of its file operands include a password.
File passwords are not easy to handle in any of the CP/M systems that support them. There are two ways for the user to specify one: by appending it to a filespec, or by setting a default password before giving the command that uses the file. If the default password is set, the user needn't give it in the infile operand; the program will be able to open the file without it. Therefore, the lack of a password in the infile operand doesn't mean that the file is unprotected; it might still have a password. However, the default password has no effect on newly created files. They don't receive a password automatically.
A new file receives its password when it is created, and the password may be applied at any of three levels: read, write, or delete. A read password is required for any use of the file; a write password must be given when the file is to be extended or updated in place; the delete password is required only to erase or rename the file. There is no convenient syntax for specifying which level of password an output file is to receive. If the outfile operand includes a password, which level should a utility apply? The answer to that one is easy, if uncomfortable. The purpose of a password is security, and you don't take chances in a secure environment. We dare not assume the user wants anything less than maximum security. The program must apply the password at the read level. (That is what pip does, by the way.) However, a file with a read-level password is an awkward thing. You must specify the password to make any use of it whatever, even to type it.
A utility must use the read level if it applies a password at all; and it cannot always tell when the input file has a password. For these reasons, it seems best not to extend the utility convention to the password field; best not, that is, supply a missing outfile password by copying one from infile. If the user wants a password on the new output file, the user can specify it explicitly as part of the output operand.
All of these considerations went into the design of the toolkit functions that support fassign and other file macros (as is discussed more thoroughly in Part II). The fassign macro will accept four parameters. Ignoring the fourth for the present, the first three are
fassign newfile,string,default
In previous programs we have used only the first two, newfile naming the filedef to receive the filespec and string naming the character string that represents the filespec (usually a command operand).
The optional third operand, default, names a second filedef, one that has already had a filespec assigned to it. After processing string into newfile, fassign checks to see if the drivecode, filename, or filetype were omitted. If a part was omitted, and if the default parameter was specified, the missing part is copied to newfile from default. The utility operand conventions can be implemented this way. Assign the first operand to a file; then present that file as the default while assigning the second operand to another file.
The feature of replacing an input file is implemented by the frewrite function. It opens a file for output from the beginning (I named it after the rewrite procedure of Pascal, which does the same job). We have used it once before, in the keyfile program.
The frewrite function supports file replacement this way. It first checks to see if a file of the same name already exists. If not, it creates a file and all is well. If a previous file does exist, however, it doesn't erase it (as a Pascal, BASIC, or C program would do). Instead, it creates a new file with a filetype of $$$. Recall our hypothetical example
A>process b:replaced.doc
in which we intend the program to read b:replaced.doc while simultaneously writing a new version of it. The program will ask frewrite to open a new file named b:replaced.doc, but frewrite won't do it. It will see the existing file, and will create instead a new file named b:replaced.$$$. This new file will receive the output of the program; the input file won't be disturbed.
When it finishes, the program will use the fclose macro to tidy up the output file. The fclose function will discover the situation, and it will do three things. It will close the new file b:replaced.$$$. That ensures that the file's data are complete and the disk directory updated. Then it will erase the previous file, b:replaced.doc. Finally, it will rename b:replaced.$$$ to b:replaced.doc.
This scheme takes care of the file-replacement problem very nicely and, since it is completely hidden in the toolkit functions, it is simple to use. It does run into some problems with password-protected files under CP/M Plus, but they are not crippling. For example, if b:replaced.doc has a delete-level password, the program will crash when fclose tries to erase the old file. But all such problems take place after the program finishes its work and the output data are secure under their temporary filetype; and the user can avoid them all by specifying the output file in full with its password.
The utility convention is a major part of the external specification of tabbit; now that it has been settled, we can specify the rest.
The tabbit command will read an input file of ASCII text and will write another text file. Its command syntax is
tabbit infile outfile
In the outfile file, multiple blanks will have been replaced by tabs wherever that can be done without changing the appearance of the file as typed. In addition, blank characters at the end of a line will have been deleted. (Such useless, trailing blanks are rare in CP/M files but common in files brought from other operating systems.)
Tabbit may be applied to any text file, but it is not recommended for use on files that will receive other processing before they are printed (or else such files should be untabbed before processing).
Tabbit, like most utilities, is basically a program to copy one file to another. After preparing the input and output files, a file copy proceeds like this:
repeat
read a unit of the input file
process it
write it to the output file
until (end of input file)
The keyfile program we wrote in chapter 3 followed that pattern. Its input file was the keyboard; the unit it handled was the line; and it did no processing. A program like tabbit introduces only three refinements: The input file will not be limited to the keyboard, but may be a file of any sort; the "unit" will usually be a single character; and there will be a processing step between reading and writing. The body of tabbit will have a shape like this.
repeat
read a character x from the input file
if (x is a blank) then
do something about converting it to a tab
write a character to the output file
until (end of input file)
Now all we have to do is figure out how to convert the blanks to tabs. Unfortunately, if we limit our thinking to the "read a byte, write a byte" model, we aren't going to find one.
In many utilities, we can make a decision about what to do with a character based solely on the character's value. If we were writing a program whose only task was to convert all digits to asterisks, for instance, we could use a plan like this.
repeat
read a character x from the input file
if (x is a digit) then x := '*'
write x to the output file
until(end of input file)
The processing step is simple because the decision to convert x is based only on the value of x.
Tabbit presents a tougher problem. The decision of whether to convert a blank to a tab depends on what follows it within the file. If it is followed by nothing but blanks up to the next tab stop, then a single tab character should be written. If not, that is, if something other than a blank occurs before the next tab stop, then the blank should be written.
The distressing thing is that, in order to discover the context, we have to read onward in the file. If we find nothing but blanks, fine; they'll all collapse into a single tab, which we'll write. But if they don't, we'll have read several characters without copying them, and must hasten to set things right. A tidy, elegant solution to this problem is by no means easy to find. It is possible to invent all sorts of over-elaborate schemes to handle it; I know, I've done it.
One of the tidy solutions runs along these lines.
repeat
read a character x from the input
while (x is a blank)
count it
read another x
end while
write as many tabs as the count permits
write any blanks not accounted for
write x
until (end of input)
The idea is to suck up as many contiguous blanks as possible. At the end while point, we have counted some number of blanks -- possible zero -- and x is not a blank. The count of blanks summarizes the fact of how much white space should precede the nonblank x. We'll have achieved the aim of tabbit when we can reproduce that white space with as many tabs and as few blanks as possible.
The opening of the plan, reading and counting blanks, and its close, writing x, will be simple. The vague part of the problem has been reduced to two lines. Given a count of blanks (which may be zero),
write as many tabs as the count permits
write any blanks still unaccounted for
The count of blanks is a count of columns; that is, a count of record, screen, or print positions that are to be skipped over. How shall we decide how many tabs such a count permits? This is my solution.
n := columns to the next tab stop
while (count >= n)
write a tab to output
count := count-n
n := 8
end while
Suppose for the moment that we are working at the first character of a line. Then the first line, n := columns to the next tab stop, amounts to only n := 8; that is, from the first position of a line there are eight columns to the next tab stop in the ninth position. The while-loop is clearly correct under that supposition. If 16 blanks had been counted starting at the first position of a line, the loop would write two tabs to the output and reduce the count to zero.
If ten blanks had been counted, that loop would write one tab and leave the count on 2. Two blanks remain unaccounted for. They can be dealt with in a loop that is very similar to the first one.
while (count > 0)
write a blank to the output
count := count - 1
end while
There is just one loose end, namely, how to calculate the number of columns to the next tab stop when we are not in the first position of a line. Let's defer that and hark back to the specification of tabbit. In it, I rashly promised to delete trailing blanks from any line. Our plan doesn't reflect that yet, but we can add it easily. Recall that, after counting blanks, the current character x must be nonblank. It might be any one of three kinds of nonblanks, however. It might be
The first case, end of file, needn't worry us too much. The ASCII-file input functions of the toolbox take pains to ensure that they return an end of line character immediately before returning end of file, even if the file lacks one. Therefore when we see end of file, count will have to be zero, since the end of file always follows end of line with no intervening characters.
That leaves two cases: x either contains an ASCII CR, indicating end of line, or it does not. If it does not, the blanks summarized in count are necessary white space and must be written. If it does, they are redundant and may be skipped. If we put all these things together, we arrive at the plan shown in Figure 6-1.
repeat
read a character x from the input
while (x is a blank)
count it
read another x
end while
if (x is not a CR) then
n := columns to the next tab stop
while (count >= n)
write a tab to output
count := count-n
n := 8
end while
while (count > 0)
write a blank to the output
count := count - 1
end while
end if
write x to the output
until (end of input)
Figure 6-1. First-draft plan for the body of tabbit.
Just one problem remains: how can we know how many columns remain to the next tab stop, regardless of the current position in the line? Well, suppose that we know what the current position is, as a count of columns beginning at 1 in the left margin. Now enumerate all the answers for the first dozen columns:
column: 1 2 3 4 5 6 7 8 9 10 11 12...
to-tab: 8 7 6 5 4 3 2 1 8 7 6 5...
It's like a question from a sophisticated IQ test: "What expression, applied to the numbers of the top line, will yield the numbers on the bottom line?" The answer jumps out at you if you number the column positions, not from one, but from zero:
column: 0 1 2 3 4 5 6 7 8 9 10 11...
to-tab: 8 7 6 5 4 3 2 1 8 7 6 5...
The expression we want is
n := 8 - (column mod 8)
where column is the current column position counting from zero.
The modulo, or mod, function crops up often in programming. Provided that we deal only with positive integers, x modulo y is simply the remainder after dividing x by y, and is always an integer between zero and y-1 inclusive. Provided we number things starting from zero, the expression number mod y has the effect of folding all possible numbers back onto the first y numbers; as here the expression column mod 8 will fold any value of column back onto the first eight column numbers, 0 to 7.
This "folding" effect of modulo works neatly only when things are numbered starting with zero, and that explains why programmers so often use origin-zero numbering schemes. Thanks to modulo, we need consider only the first eight column numbers, and the expression 8-number yields the count of positions up to the next tab stop.
Modulo is particularly easy to implement when working with binary numbers and y is an integral power of two. Eight is such a power of two; as a result, column mod 8 amounts to nothing more than extracting the three least-significant bits of column. In other words,
column mod 8
column AND 0111b
have exactly the same result. No doubt that's why the CP/M tab increment is eight, not ten.
We are squeezing the unknown parts out of the plan. Now we have an expression that will set up our tab-producing loop, but it requires column as input. That variable must contain a running count of the current output column position. We could think of it as an analogue of the physical position of a print-head or cursor as it displays the file. Every character that we write to the output file must have the same effect on column that it would have on the position of a cursor or print-head.
An ordinary character should make column increase by one. A CR should reset it to zero. A tab character should advance it to the next tab stop, and a backspace character should decrease it by one. The first of these are obvious, but backspace is a bit tricky. A real printer won't backspace out of column 1, so when writing a backspace code we mustn't decrement column lower than zero. (Of course, backspace is an unlikely character to find in a disk file, but if we disregard it, some user will apply tabbit to a file that contains one and report a bug. Besides, who says tabbit's input file has to be a disk? It might be CON:.)
Advancing column to the next tab stop amounts to rounding it up to an even multiple of eight. Since eight is a power of two, we can do that with the expression
column := 1 + (column OR 0111b)
When we augment Figure 6-1 with statements to maintain and use column, the result is Figure 6-2, and the plan for the body of tabbit is complete.
count, column, and n are integers
x is a character
column := 0
repeat
x := fgetchar(input)
count := 0
while (x = AsciiBlank)
count := count + 1
x := fgetchar(input)
end while
if (x <> AsciiCR) then
n := 8 - ( column mod 8 )
while (count >= n)
fputchar(output,AsciiTAB)
column := column + n
count := count - n
n := 8
end while
while (count > 0)
fputchar(output,AsciiBlank)
column := column + 1
count := count - 1
end while
end if
if (x < AsciiBlank) then
if (x = AsciiCR) then
column := 0
elif (x = AsciiBS) and (column > 0) then
column := column - 1
elif (x = AsciiTAB) then
column := (column OR 0111b) + 1
endif
else if (x < AsciiDEL) then
column := column + 1
endif
fputchar(output,x)
until (end of input)
Figure 6-2. Final plan for the body of tabbit.
The plan in Figure 6-2 is complete for the body of the program, but it omits the initialization and termination steps. What needs to be done? The first step, as in previous programs, will be to check the number of operands and terminate with a usage message if necessary. The chkops module will handle that. Then the input file must be assigned and opened. That process will be similar to the same step in the cdump and bdump programs. The openin module would suffice for it.
The third step of initialization will be to open the output file. This will be a new process for us. We will want to assign the second command operand to a file, but we will want to include in the assignment a default file -- the assigned, opened input file. After the assignment, the file must be opened with the frewrite macro to prepare it for output.
How shall we handle this step? It should be generalized and isolated in a module for use with other utilities. However, the assignment of an operand string to the output file is subject to the same two errors that can afflict the input file; that is, the operand could turn out to be an invalid filespec, or it could turn out to be ambiguous.
The openin module that we created in chapter 5 already contains apparatus to construct error messages for these situations. With its code, string constants, and working space, this apparatus occupies a couple of hundred bytes. It would be a shame to duplicate all that in another module, causing all utilities to contain two copies of those error messages. Instead, let's create a module that performs both kinds of opens that a utility needs.
The code of this module, openut, appears in Figure 6-3. I created it by editing openin. The openin module is still useful; it contains the minimum function needed by a program that only reads a file. Many such programs can be written.
The openut module in Figure 6-3 is a superset of openin. It contains a public function named openin that does exactly what the simpler module does -- assign an operand to a file and open it for input, aborting if the file is empty or not found. It contains an additional function, openou (public names are limited to six characters, unfortunately). This function assigns an operand to a file with a default, and opens the file for output.
The operand-assignment operation is common to both of these functions; it has been extracted into a small subroutine. The business of building an error message has been disentangled from the rest of the code as well; in fact, the structure of openin has been cleaned up generally to make openut.
name 'OPENUT'
;===============================================================
; OPENUT module
;
; Function OPENIN( A=operand number, DE->filedef)
; Acquire the given operand string and assign it to the filedef,
; then open the file for input. If problems, abort with one of
; three messages:
; FRUMMAGE is not a valid filespec.
; FRUMMAGE is ambiguous; an explicit name is needed.
; FRUMMAGE is empty or does not exist.
;
; Function OPENOU( A=operand, DE->filedef, BC->default filedef)
; As for OPENIN, but allows a default filedef, opens for output
; with frewrite and hence doesn't care if file is empty.
;
maclib environ
dseg
is: strconst ' is '
inval: strconst 'not a valid filespec'
ambig: strconst 'ambiguous; an explicit name is needed'
empty: strconst 'empty or does not exist'
dotdol: strconst '.$'
msg: strspace 64
cseg
entersub
public OPENIN
OPENIN:
push psw
push b
push d
push h
lxi b,0 ; no default filedef for input
call Assign ; assign & check the operand
freset +D ; try to open it
lxi b,empty ; (but assume we can't)
cz Error ; (we couldn't)
pop h
pop d
pop b
pop psw
ret
public OPENOU
OPENOU:
push psw
push b
push d
push h
call Assign ; try the assignment
frewrite +D ; and open for output
pop h
pop d
pop b
pop psw
ret
; Internal subroutine Assign(A=op#, DE->file, BC->dflt) --
; Attempt the assign of operand to file with default and
; abort if necessary.
Assign:
push d ; save ->file
tailtokn +A ; get DE->operand string
xchg ; and move to HL
pop d ; recover DE->file
fassign +B
lxi b,inval ; (set up for invalid)
cz Error ; oops, not a valid filespec
cpi '?' ; was it ambiguous?
lxi b,ambig ; (prepare in case it was)
cz Error ; (yes)
ret ; assigned ok...
; Internal subroutine Error(BC->submessage, HL->operand)
; Not really a subroutine because it always aborts and never
; returns -- construct an abort message including the operand
; string and abort the program.
Error:
; copy the operand to the message field first
lxi d,msg
strcopy +Hupdate
; add the word " is "
lxi h,is
strappnd +Hupdate
; then add the specific explanation
mov h,b ! mov l,c
strappnd +Hupdate
; finish up with a period and a dollar sign
lxi h,dotdol
strappnd +Hupdate
; that does it -- byebye
lxi d,msg
abort ,+D
end
Figure 6-3. Assembly code of the module openut.
The plan for tabbit was so thorough that the assembly-language source fell into place quickly and easily (it appears in Figure 6-4). It opens with calls on chkops, openin, and the new openou, and closes with an fclose of the output file. The code to implement Figure 6-2 falls between these, but I chose to make that a subroutine so as to make the main line of initialization and termination clearer.
;===============================================================
; TABBIT input output
; Reads an ASCII file and converts sequences of blanks to tabs
; wherever possible. Also deletes trailing blanks on a line.
; Abort messages:
; file-open messages
; usage message
; Modules
; CHKOPS
; OPENUT (OPENIN, OPENOU)
; History
; Initial code 6 July 1984
;===============================================================
maclib environ
dseg
usage:
db AsciiTAB,'TABBIT infile outfile'
db AsciiCR,AsciiLF
db 'Tabbit reads the input file and converts sequences of'
db AsciiCR,AsciiLF
db 'blanks to tabs where possible. It also deletes blanks'
db AsciiCR,AsciiLF
db 'at the ends of lines.'
db AsciiCR,AsciiLF
db 'The input file should be all-ASCII, and its data should'
db AsciiCR,AsciiLF
db 'not be intended for further processing before printing.'
db AsciiCR,AsciiLF,'$'
input: filedef 128 ; small input buffer
output: filedef 16384 ; big output buffer for speed
cseg
prolog
extrn CHKOPS,OPENIN,OPENOU
mvi b,1 ; at least one operand
mvi c,2 ; but not more than 2
lxi d,usage
call CHKOPS ; chkops(min,max,usage)
mvi a,1 ; 1st operand is input
lxi d,input
call OPENIN
mvi a,2 ; 2nd is output (may be null)
lxi b,input ; default filespec is "input"
lxi d,output
call OPENOU
call Process ; do the main loop
fclose output ; close the output file
ret ; and end
;======= Body of program (from pseudo-code) ========
Process:
;.column := 0
lxi b,0 ; carry column in BC
lxi d,input ; carry DE->input file
;.repeat
mainloop:
;.....x := fgetchar(input)
fgetchar +D
;.....count := 0
lxi h,0 ; count in HL
;.....while (x = AsciiBlank)
readblank:
cpi AsciiBlank
jnz notblank
;.........count := count + 1
;.........x := fgetchar(input)
inx h
fgetchar +D
;.....end while
jmp readblank
notblank:
;.....if (x <> AsciiCR) then
cpi AsciiCR
jz isCR
push psw ; save the byte "x"
push d ; save ->input, free up DE
;.........n := 8 - ( column mod 8 )
; note: since the expression needs only the low bits of
; the count, we can reorder it to (-(column mod 8)) + 8
; and do it all in the A-register.
mov a,c ; low-order bits of column
ani 0111b ; A = column mod 8
neg a ; A = -(column mod 8)
adi 8 ; A = (-(column mod 8))+8
mov e,a
mvi d,0 ; DE = 8-(column mod 8)
;.........while (count >= n)
tabloop:
cmpbw +D ; flags := count-n
jc tabend ; count < n
;.............fputchar(output,AsciiTAB)
mvi a,AsciiTAB
fputchar output,+A
;.............column := column + n
;.............count := count - n
ora a ; clear carry
dsbc d ; count := count-n
xchg ; (DE=count, HL=n)
dad b ; HL = column+n
mov b,h !mov c,l; move it back to BC
xchg ; (restore HL=count, DE=junk)
;.............n := 8
lxi d,8
;.........end while
jmp tabloop
tabend:
;.........while (count > 0)
blankloop:
mov a,h ! ora l ; test HL (count) for zero
jz blankend
;.............fputchar(output,AsciiBlank)
mvi a,AsciiBlank
fputchar output,+A
;.............column := column + 1
;.............count := count - 1
inx b
dcx h
;.........end while
jmp blankloop
blankend:
;.....end if
pop d
pop psw
isCR:
;.....if (x < AsciiBlank) then
cpi AsciiBlank
jnc notCC
;.........if (x = AsciiCR) then
;..................column := 0
cpi AsciiCR
jnz notCR
lxi b,0
jmp writex
notCR:
;.........elif (x = AsciiBS) and (column > 0) then
;..................column := column - 1
cpi AsciiBS
jnz notBS
mov h,a ; save "x"
mov a,b !ora c ; test column for zero
mov a,h ; restore "x"
jz writex ; (column = 0, don't back up)
dcx b
jmp writex
notBS:
;.........elif (x = AsciiTAB) then
;..................column := (column OR 0111b) + 1
cpi AsciiTAB
jnz writex
mov h,a ; save "x"
mov a,c
ori 0111b
mov c,a ; BC = column OR 0111b
inx b ; ..plus one
mov a,h
jmp writex
;.........endif
notCC:
;.....else if (x < AsciiDEL) then
;.........column := column + 1
cpi AsciiDEL
jnc writex
inx b
;.....endif
writex:
;.....fputchar(output,x)
fputchar output,+A
;.until (end of input)
cpi CpmEof
jnz mainloop
ret
end
Figure 6-4. The assembly-language source code of tabbit.
That subroutine, process, is another direct translation from the pseudo-code plan to assembly language, but a bit more complicated than previous efforts. The architecture of the Z80 CPU complicated matters at three points.
A pseudo-code loop or if statement must be implemented with conditional jumps. The program must prepare the CPU's flag registers properly before taking a conditional jump. And almost the only way of setting the flags is to perform some operation in register A. For instance, the pseudo-code
while (count > 0)
might, if "count" is being carried in registers HL, become the assembly statements
mov a,h ! ora l ; test HL for zero
jz outofloop
That's fine, so long as there was nothing of interest in register A beforehand. In tabbit, however, I was carrying the all-important variable x, the current nonblank input byte, in register A. At three points it had to be set aside temporarily while register A was used to set up for conditional jumps. In one case, the variable x was set aside over a long stretch of code by pushing it onto the stack; in two others it was tucked away in another register that was unused at that point. Find these points in Figure 6-4 and then ask yourself what they imply for the maintainability of the code. Imagine someone a year from now wanting to add a new function to tabbit, a function that needs another register. Might these temporary saves of x be missed, or fouled up somehow, so as to cause a bug? What could be done to make things easier for that person?
The main reason for using assembly language is efficiency; that is, getting a smaller, faster program. It might occur to you that Figure 6-2 is so detailed that it is almost ready for compiling in C or Pascal as it stands. What is gained by translating it into assembly language?
In order to find out, I translated Figure 6-2 into the C language and compiled it with a popular 8-bit implementation of C. The translation was straightforward, although not as simple as I thought it would be. You can read it in Figure 6-5. Here are the results.
/* "tabbit" in C (both filespecs must be given in full) */
#include "stdio.h"
#define CR '+n'
#define BS 0x08
#define TAB 0x09
#define BLANK 0x20
#define DEL 0x7f
#define ABORT(X) fputs(X,stdout);exit();
int count, column, n;
int x; /* has to be "int" to test for EOF */
FILE *infile, *outfile;
main(argc,argv)
int argc; /* count of operands, includes 1 for program-name */
char *argv[];
{ /* "chkops" */
if ((argc != 3) || ('?' == *argv[1]))
/* "openin" */
if (NULL == (infile = fopen(argv[1],"r")) )
/* "openou" */
if (NULL == (outfile = fopen(argv[2],"w")) )
process();
}
process()
{
column = 0;
do
{
x = agetc(infile);
count = 0;
while (x == BLANK)
{
count++;
x = agetc(infile);
}
if (x != CR)
{
n = 8 - ( column & 0x0007 );
while (count >= n)
{
aputc(TAB,outfile);
column += n;
count -= n;
n = 8;
}
while (count > 0)
{
aputc(BLANK,outfile);
column++;
count--;
}
}
if (x < BLANK)
{
if (x == CR) column = 0;
else if ((x == BS) && (column > 0)) column--;
else if (x == TAB) column = (column | 0x07)+1;
}
else if (x < DEL) column++;
aputc(x,outfile);
} while(x != EOF);
}
Figure 6-5. The tabbit program written in C.
The C source file is considerably shorter at 1.4 kilobytes (Kb) versus 4.25 Kb. The difference would be less if the C file had a proper prologue and more comments (as it should); even so, the assembly language will always be at least twice as large (and will presumably take twice as long to compose, all else being equal).
The programs took about the same length of time to compile (assemble) and link -- the assembly version was ready to run after 60 seconds, the C version after 73 seconds. This relationship would vary with the C compiler used; some are faster and some slower than the one I used. In any case, the cycle of edit, compile, and test takes about the same length of time for either language.
The assembly program was smaller, as it should be. Tabbit in assembly language, linked with all the toolkit modules it uses, comes to 4.0 Kb. The C version, linked with this compiler's run-time library, came to 9.8 Kb, more than twice as large. This ratio would have been much larger in favor of assembly language had I not been careful to avoid the printf() function of C which requires elaborate library support.
In execution the assembly program was much the faster. It processed a 5.3 Kb input file (its own source with all tabs converted to blanks) in 5.8 seconds; the C program took 9.8 seconds to process the same file.
We've done tabbit; now let's address the opposite problem. Suppose that we have an ASCII file the contains tabs, and we don't want the tabs. Perhaps we ran tabbit on a file and then realized that its contents would be rearranged at output, making the tabs invalid. Or perhaps we have downloaded this file from a mainframe system that defines a different tab-increment than CP/M's eight. We want the tabs replaced by the blanks that they stand for. Now in fact, the pip program will do exactly that, so it isn't really necessary to write a special program for the purpose. However, it is an interesting problem. In particular, it gives us an excuse to write a utility that takes an option.
The untab utility will processes an ASCII file to remove all tabs and replace them with the blanks that they represent. It will assume that the tabs in the file are based on some regular increment. Unless the increment is specified otherwise, an increment of 8 (the customary CP/M increment) will be used. However, a different increment between 1 and 255 may be specified as an option in the command line.
The syntax of untab is
A>untab infile outfile [increment]
The file references are given as for other utilities. Omitted parts of the output reference are supplied from the input reference. If the output reference is omitted entirely, the input file will be replaced.
The option [increment] may be omitted. If it is, the command will assume a tab increment of 8. A different increment may be specified as a decimal number enclosed in square brackets. For example,
A>untab thisfile thatfile [10]
will produce "thatfile" from "thisfile" using a tab increment of 10.
It is only recently that Digital Research has attempted to standardize the syntax of options in commands. Think of the variety of ways of coding options. The asm assembler takes parameters as single letters in the filetype position. The mac and rmac assemblers take two-letter parameters following a dollar sign. The use of a hyphen is becoming popular in some circles. Other writers have used the slash as a delimiter for parameters. Indeed, almost every ASCII punctuation character has been used to distinguish an option from a filespec by some program or other. Which one ought we to use? After some thought, it seemed best to me to use the square brackets.
If you are using CP/M 2.2, you are used to giving options within square brackets, but only to pip, as for instance when you enter
pip b:=a:*.asm[v]
However, the newer Digital Research operating systems -- MP/M 2, CP/M Plus, and Concurrent CP/M -- use these square brackets in many commands. The link and lib programs that we use with the rmac relocating assembler follow this custom as well.
The toolkit functions were designed with the square-bracket usage in mind. In particular, fassign takes the left and right square-bracket characters to be filespec delimiters. Either one stops its scan over a filespec; the contents of a string preceding the bracket are part of the filespec but the bracket itself is not.
This fits in well with the utility convention for filespec operands. If the outfile operand is omitted, the option will appear as the second command operand. We needn't make a special test for this case; we can pass the operand string (of, say, [10]) to fassign and it, finding nothing to the left of the filespec delimiter "[," will treat the operand as a null string for file-assignment purposes.
As we did with tabbit, let's defer planning the initialization steps and go to work on the processing loop. Unlike tabbit's processing loop, that of untab can follow the simplest utility model:
repeat
read a byte x
do something with it
until (end of input)
In this case, "doing something" with an input byte comes down to two cases: the input byte is a tab, or it isn't. If it is a tab, we want to write one or more blanks in its place. We can formalize the idea this way.
repeat
get a byte x
if (x is a tab) then
write the number of spaces it stands for
else
write x
endif
until (end of input)
The code is simpler than tabbit's because no look-ahead is needed. In tabbit, the appearance of a space marked a point at which the program might be able to substitute a tab. The uncertainty could only be resolved by reading further in the file. In untab, the appearance of a tab signals a point at which spaces will be substituted.
Ah, but how many spaces? Two factors determine the answer -- the tab increment, and the current position within the file. The latter is a column count, just like the one we maintain throughout tabbit. The tab increment will be either eight, or the number given as an option in the command.
If we assumed a fixed tab increment of 8, the answer would be simple to obtain. It would be
8-(column mod 8)
the same expression that we used in tabbit to compute how many positions remained to a tab stop. But that expression is just a special case of
tabincr - (column mod tabincr)
where tabincr is any regular tab increment. In tabbit we used a fixed increment of 8, and that made modulo an easy function to implement. Here we must take column mod tabincr when tabincr may be any number from 1 to 255. Since both are positive integers, "modulo" and "remainder" are identical (remainder and modulo are not the same function when either argument is negative, a point that is often overlooked). We have will have to perform a division of a 16-bit column value by an 8-bit tab increment. The toolkit functions contain such a divide routine.
With these things in mind, we can lay out a fairly detailed plan for untab (Figure 6-6). Two lines are still fuzzy in that plan. One of them is "adjust column for the effect of x." That's just shorthand for the same set of ifs and elses that appear at the end of tabbit; there are no surprises in it.
program untab(in, out, option)
tabincr, column, n are integers
infile, outfile are files
chkops(1,3)
openin(1,infile)
openou(2,outfile,infile)
extract tab-increment from option to tabincr
repeat
x := fgetchar(input)
if (x = AsciiTAB) then
n := tabincr - (column mod tabincr)
do n times: fputchar(output,AsciiBlank)
column := column + n
else
fputchar(output,x)
adjust column for effect of x
endif
until (x = CpmEof)
close outfile
end untab.
Figure 6-6. Plan for the untab program.
The other fuzzy area is expressed by the line "extract tab-increment from option." That problem breaks down immediately into two problems: (1) locate the option operand-string, and (2) if there was one, extract a number from it. At first glance, neither of these steps looks too hard.
Finding the option operand isn't too hard. As we have specified the command's syntax, the option must be the last operand. We wrote the chkops module so that it returns in register A the number of operands that appeared in the command. If there were three operands, the option operand is the third; if two, it's the second. So a call on the toolkit function tailtokn, passing the same number that chkops returned, will yield the address of the last operand-string.
Of course, if the user enters only the two filespecs, the last operand string will be the outfile operand, not an option. We can eliminate that case by looking for a left-bracket character as the first byte of the string.
Once we have the option-operand as a string, it is not difficult to extract a number from it. We must step over the opening bracket character, then use stradbw to convert ASCII digits to a binary word. Then we should check the result to see if it is in the specified range of 1-255.
All this is not hard to do, but it doesn't add up to a sophisticated user interface, either. Consider what the user might try, and how the program would react to it. For instance, the user might omit both file operands.
untab [11]
In this case, the program would ask openin to assign and open a file based on the first operand, [11]. But the bracket is a filespec delimiter to fassign, so the input filedef would be assigned a filename and filetype of all blanks; that file would not be found, and the program's response would be to abort with the message
[11] is empty or does not exist.
That doesn't match the conventional idea of "user friendly," but at least it would alert the user that something is wrong.
Another possibility is that the user would run the option up against the output filespec, as pip requires.
untab thisfile thatfile[16]
In this case, the two files would be opened correctly, but our scheme for locating the option would fail. The toolkit macros split the command "tail" into operand strings only at blanks or commas, so this command has but two operands: thisfile and thatfile[16]. The program would check the second operand for a leading bracket, fail to find it, and continue with the default increment of eight instead of the desired 16.
A third glitch appears if the user inserts any spaces into the option.
untab this that [ 16 ]
Now there are not three, not four, but five operands, and chkops will abort the program with the usage message.
Other erroneous combinations are possible, and it would take a very sophisticated program to analyze for all possible errors. Such analysis might be easier if the toolkit function savetail were designed to treat the square brackets in special ways. It could, for example, force any left- or right-bracket to become an isolated, one-character, operand. Then a program would receive all possible cases of bracketed options as if they had been entered as space, bracket, option, space, bracket. (But consider the effect of such a rule on a program like emit!)
This is the not case, however; and although under the existing scheme it is possible for a utility to look fairly stupid, I don't feel disposed to fix it. Perhaps you would like to put a more intelligent option-analyzer into it.
The assembly code of UNTAB appears in Figure 6-7. Like tabbit, it consists of a main-line routine that performs initialization and termination, and a subroutine process that implements the pseudo-code plan. Another, smaller subroutine locates and extracts the tab-increment option.
;===============================================================
; UNTAB infile outfile [tabinc]
; Read infile, convert tabs to spaces, write as outfile. Assumes
; a regular tab increment of tabinc (default is 8 if [tabinc])
; not given.
; Note: the option [tabinc] must be a separate token and must
; not contain embedded spaces or commas.
; Abort messages:
; file-open messages
; usage message
; "option must be [nn] where "nn" is a decimal
; number between 1 and 255."
; Modules
; CHKOPS
; OPENUT (OPENIN, OPENOU)
; History:
; initial code 10 July 84
;===============================================================
maclib environ
dseg
usage:
db AsciiTAB,'UNTAB infile outfile [nn]'
db AsciiCR,AsciiLF
db 'Reads ascii file "infile," converts tabs to spaces, and'
db AsciiCR,AsciiLF
db 'writes "outfile." Tab-conversion assumes a regular tab'
db AsciiCR,AsciiLF
db 'increment of "nn" with a default of 8 (normal CP/M) when'
db AsciiCR,AsciiLF
db 'the option "[nn]" is omitted.'
db AsciiCR,AsciiLF
db '$'
infile: filedef 128 ; small input buffer
outfile: filedef 16384 ; big buffer for speed
tabincr: ds 1 ; space for tab increment
opcount: ds 1 ; save number of operands
cseg
extrn CHKOPS,OPENIN,OPENOU
prolog
; chkops(1,3)
mvi b,1 ; at least one,
mvi c,3 ; not more than 3, operands
lxi d,usage
call CHKOPS ; check, get count
sta opcount ; ..of operands and save
; openin(1,infile)
mvi a,1
lxi d,infile
call OPENIN
; openou(2,outfile,infile)
mvi a,2
lxi d,outfile
lxi b,infile ; (default file)
call OPENOU
; extract tab-increment from option to tabincr
lda opcount
call getincr ; do it in a subroutine
sta tabincr
call process ; do the work
lxi d,outfile
fclose +D
ret
;===============================================================
; getincr(op#) returns increment -- extract a number from
; the option [incr] if we can find it, return in A.
dseg
badop:
db 'option must be [nn] where "nn" is a decimal number'
db AsciiCR,AsciiLF
db 'between 1 and 255.'
db AsciiCR,AsciiLF
db '$'
cseg
getincr:
push h
push d
push b
tailtokn +A ; DE->given operand string
ldax d
cpi '[' ; start of option?
mvi a,8 ; (assume not)
jnz default
inx d ; yes, step over bracket
stradbw +Dupdate; extract digits to word in HL
; result of stradbw is zero in two cases: if the number
; was actually zero ([000]) or if there were no decimal
; digits ([abc]).
mov a,h ! ora l ; check for zero...
abort z,badop
mov a,h ! ora a ; check for HL > 255...
abort nz,badop
mov a,l ; put increment in A
default:
pop b
pop d
pop h
ret
;===============================================================
; process: the main loop, from the pseudo-code, moved out of
; line for clarity. As part of the main-line, we don't bother
; to save the registers.
process:
lxi d,0 ; carry "column" in DE
; repeat
procloop:
; x := fgetchar(infile)
fgetchar infile
; if (x = AsciiTAB) then
cpi AsciiTAB
jnz notTab
; n := tabincr - (column mod tabincr)
lda tabincr ; A = tabincr = divisor
mov h,d ! mov l,e ; HL = column = dividend
div816 +A ; HL = HL/A, A = remainder
neg a ; A = -(column mod tabincr)
lxi h,tabincr
add m ; A = tabincr-(column mod tabincr)
mov b,a ; put in B as loop-count
; do n times: fputchar(output,AsciiBlank)
; column := column + n
mvi a,AsciiBlank
outblank:
fputchar outfile,+A
inx d ; increment column for each
djnz outblank
; note: at the end of this then-section we know we didn't
; have CpmEof (we had a tab) so we can short-circuit the
; loop test and continue at the top of the loop
jmp procloop
; else
notTab:
; fputchar(output,x)
fputchar outfile,+A
; adjust column for effect of x (from tabbit)
; if (x < AsciiBlank) then
cpi AsciiBlank
jnc notCC
; if (x = AsciiCR) then
; column := 0
cpi AsciiCR
jnz notCR
lxi d,0
jmp endloop
notCR:
; elif (x = AsciiBS) and (column > 0) then
; column := column - 1
cpi AsciiBS
jnz endloop
mov h,a ; save "x"
mov a,d !ora e ; test column for zero
mov a,h ; restore "x"
jz endloop ; (column = 0, don't back up)
dcx d
jmp endloop
notCC:
; else if (x < AsciiDEL) then
; column := column + 1
cpi AsciiDEL
jnc endloop
inx d
endloop:
; until (x = CpmEof)
cpi CpmEof
jnz procloop
ret
Figure 6-7. The assembly source of untab.
For the most part, translating pseudo-code to assembly source is a straightforward business, very easy to do with a good full-screen editor. Begin with a unit, like a repeat-loop, and make assembly comments of it.
; repeat
; (various statements)
; until (x = CpmEof)
Insert a label at the top of the loop, and add the code that will implement the loop condition.
; repeat
procloop:
; (various statements)
; until (x = CpmEof)
cpi CpmEof
jnz procloop
Then pick the next-innermost statement and implement it. Each form of pseudo-code statement has an arrangement of necessary labels and segments of assembly code that you can apply almost mechanically (just as a compiler would do). An if statement, for example, needs a false-label and (if it has an else part) an end-if label. Take this one, for example.
; if (x = Tab) then
; (various statements)
; else
; fputchar(outfile,x)
; endif
Before doing anything else, insert the necessary labels.
; if (x = Tab) then
; (various statements)
notTab:
; else
; fputchar(outfile,x)
; endif
wasTab:
Then implement the condition of the if and the escape at the end of the then part.
; if (x = Tab) then
cpi AsciiTAB
jnz notTab
; (various statements)
jmp wasTab
notTab:
; else
; fputchar(outfile,x)
; endif
wasTab:
The statements within the then- and else-parts may then be implemented.
This almost-mechanical scheme works, but isn't always desirable. Simple compilers for high-level languages do the same sort of mechanical translation, and they produce awful code. One example shows up in the process subroutine of Figure 6-7. Its repeat-loop contains only an if statement. The then-part of that statement is executed only when x is a tab, while the loop condition is x=CpmEof. Only the most intelligent of optimizing compilers would recognize what is obvious to a human programmer: if the then-part of the if is executed, the loop condition cannot possibly be true (and there are no true optimizing compilers available for microcomputers).
A mechanical translation of the if, such as the one outlined above, would have the then-part of the statement end with a branch to the end of the if, and thus to the loop-test which couldn't possibly be satisfied. In Figure 6-7 you will see that this redundant test has been short-circuited by a jump back to the top of the loop.
We have built a matched pair of utilities and in the process have done battle with several issues in program design: handling defaults in command operands, opening files for sequential output, processing a stream of bytes with and without lookahead, and a less-than-perfect method of handling command options.
File-transforming utilities like these ("filters," they are sometimes called) are among the best candidates for assembly-language implementations. They are usually based on simple algorithms that aren't hard to code in assembly language, and when that is done, they usually reward us by being much smaller and faster than they would be in a high-level language. We will build several more in following chapters, but there are an endless number you could construct to the same pattern to suit your own needs
Here we continue to practice the construction of utility programs with pack, a program that will compress most text files to 66% of their original size, and a matching unpack program to restore them to usable form.
Disks are never quite large enough; they always seem to fill up when we need to store just one more file on them. That's especially true of the current generation of inexpensive CP/M machines that use 5-inch diskette drives.
A data-compression utility can help. A compression utility reads a file and writes a version of it that is smaller, yet contains all its original information. Since compressed files take up less disk space, more of them can be put on a disk. A compressed file must be restored to its original form before it can be used, but its compressed form is fine for backup and archival storage.
There are a number of methods for compressing data. They all work by predicting some kind of redundancy in the data and encoding the data so as to eliminate that redundancy. The methods differ in the kinds of redundancy they predict and in the encoding schemes they use to eliminate it. They all have one flaw: if the predicted redundancy doesn't occur, they don't compress -- their output is no smaller than their input and may indeed be larger.
The most effective compression schemes assign codes of different lengths to the units of data, with the shortest codes assigned to the most common units. If a "unit" is a character, such a method might work as follows. First take a census of all the characters in a file. From it, assign each character a rank order; that is, give the most-frequent character the rank 0, the next most common the rank 1, and so on. Initialize the output file by writing the table of characters in rank order. Then, for each input character, write binary 1s corresponding to its rank, followed by a single binary 0. Thus the code for the most common character is a single zero-bit; the code for the next is 10; for the third 110, and so on.
To restore such a file, begin by reading the table of rank-ordered characters and saving it. Then read the rest of the file one bit at a time. Count one-bits until a zero-bit is seen, then write the character from the rank table that corresponds to the count of one-bits.
This is essentially the algorithm used by the public-domain "squeeze" utilities. It can compress any kind of data, even binary data like an object file, provided only that the frequency distribution of byte values is at least moderately skewed. In its pure form, however, it requires reading the input data twice, and the rank-table of characters inflates the size of small output files. Both of those objections can be removed: if we assumed a single, fixed rank order for all files (based, perhaps, on a one-time census of thousands of files) there'd be no need to take a census of the input or to write a rank-table. Even so, the algorithm is a complicated one to implement, and because of all the fiddling with strings of bits of varying lengths, a hard one to make fast.
Another class of compression algorithms are called "adaptive" because, rather than taking a census of the data beforehand or making a statistical prediction about its contents, they formulate the encoding on the fly, as the data is being processed.
An adaptive compressor begins with a large table in which it can store input units. The table is initially empty. In its simplest form, the method proceeds as follows.
read the next unit
look it up in the table
if (it was found) then
write its table-index
else
write the unit itself
enter it to the table
endif
The restoration algorithm begins its work with an empty table of exactly the same size. Its logic is
read the next compressed unit
if (it is a table-index) then
write the data unit from that table entry
else t
write the data unit
enter it to the table
endif
Adaptive methods rely on the prediction that, if a certain data unit occurs once, it will likely occur several times more. On its second and later appearances, a given data unit will be written as a table-index that is shorter than the unit itself.
A "greedy" adaptive compressor doesn't stop when it finds a unit that appears in the table. Instead, it reads the next text unit and appends it to the table-index for the first one, treating the pair as a single unit. If the combined unit also appears in the table, it replaces the pair with that new table-index and continues. Only when it finds a unit (or combination of indexes and units) that isn't in the table does it write anything. In this way it can build up single table-indexes that stand for long sequences of text units. The matching program that restores such a file must be prepared to handle such nested sequences.
Before commencing this chapter I wrote an experimental adaptive compression program for ASCII text (I used a high-level language for this prototype work). I hoped to find a better method than the one that will be used here, but the results were disappointing. The program was much more complicated than ones to be developed here, yet it didn't compress significantly more. The complexity of the program was, I think, inherent in the algorithm, but its lack of effectiveness may have been due to a poor implementation (I have seen better results reported in the literature). One problem may have been that, in my prototype, a "unit" was a byte and a table-index was a 16-bit word. As a result, the algorithm couldn't achieve compression until it had seen enough input to have built up many 3-byte codes in its table. It's quite possible that the results would have been better if the unit of compression had been the English word; however, dealing with units that were variable-length strings would have made the implementation even more complicated.
For some types of data we can predict that a file will contain long runs of identical units. Think of the lines of asterisks or equal-signs that decorate boxed comments in many programs, for one example. Pictorial data, such as a digitally-encoded photograph, is notorious for containing long runs of data units that all stand for the same shade. Many mathematical matrices are "sparse," containing a majority of zeros.
Such a run of identical units can be compressed into a three-unit recipe: this is a run ... of n units ... of this value. The code that says "this is a run" must be reserved; that is, it mustn't appear normally in the data (or it must be "escaped" with yet another reserved unit when it does).
Run-length encoding is unusual among compression methods in that it cannot produce output that is larger than its input. If there aren't any runs, there won't be any run-codes. If there are runs, run-length encoding will shorten the output. I know of one mainframe time-sharing system in which the system command for data compression consists of nothing more than a simple run-length encoder. It's quite effective, too, but only because in that system tabs are not supported and, worse, text files have fixed-length records padded to 80 bytes with spaces.
Unfortunately, run-length encoding is not often effective for CP/M text files. In them, runs of spaces are already compressed into tabs, and trailing blanks are almost never found. (In a sense, the CP/M convention for tab characters is form of run-length encoding. A tab is a single unit that says "this is a run of spaces," with the length of the run left implicit.) Still, runs do occur, especially in well-commented source programs. We will incorporate run-length encoding as a subordinate part of the pack program.
If we confine our attention to files of English text (and to program source files, which contain a fair proportion of almost-English text), we can make a different set of predictions about the data. For centuries, cryptographers and typesetters have known that the distribution of letters in English and the Romance languages is heavily skewed in a predictable way. The most frequent letters of English text, in approximate rank order, are etaoinshrdlu (the exact rank order can vary slightly from document to document). In computer files, the space character must be added at the head of the list; it occurs even more frequently than does e.
I reasoned some time ago that, since these letters are the most frequent, pairs of them must also be among the most frequent letter-pairs ("digraphs") to appear in a text file. It turned out to be quite easy to write a program that looked for letters in the set (space, etaoinshrdlu) and, if one was followed by a letter from the set (space, etaoins), to pack the two letters into a single byte. The program compressed a text file by 25-30%.
I published a version of that program in a magazine article and a reader, Jerry Fowler, wrote back with a thoughtful comment. "Once the first letter has been determined, there is a given probability that the next letter may or may not include one of your fixed second list. If the choice of second letters was predicated on the value of the first letter, a further compression should occur."
Fowler was perfectly correct. When it found a t, the simple algorithm looked only for a next letter from (space, etaoins). While the pairs t-space, te, tt, ta, to, and perhaps ts would occur fairly often, the pairs ti and tn were unlikely. Worse, the pair th was completely neglected. What the method needed was a choice of second letters that was different for each first letter.
I wrote a program (in a high-level language, since it would only be used a few times) that would read a text file and take a census of every digraph that began with one of (space, etaoinshrdlu). I ran it against files amounting to 250,000 bytes (the text of a book), and found the results that are summarized in Table 7-1. In that table, each row lists the eight letters that most often followed the letter that heads the row, in descending order of frequency.
This table is not to be taken as definitive for English text in general. It can't be; it is based on letter frequencies as they appear in one book by one author. Furthermore, only the first few letters in each row had distinct frequencies of occurrence. Once past the fourth follower-letter, frequencies were small and similar. This suggests that, given a different sample, while the first three or four follower-letters would almost certainly appear in roughly the same positions, the rest might be different. In the first row, followers of the space, the true second-place follower was "other"; that is, uppercase letters and nonalphabetics. Despite these shortcomings, the information in u is good enough to be the basis of an effective compression method.
This method searches a file for letters in the common set. It copies ones that are not to its output. When it finds one of the thirteen common characters, it tests the following byte against the appropriate row of u. If the second character is found, the pair of letters can be compressed into a single byte. The byte is formed from the number at the head of the row and the number at the head of the column.
The pack and unpack programs form a complementary pair. The purpose of pack is to compress a file of text, while unpack will restore a compressed file to its original form. A compressed file takes less disk space, so more files can be gotten onto a small diskette. However, a compressed file is useless for anything but storage; it contains many unprintable bytes and must be restored by unpack before being printed or processed by other programs.
The compression method of pack works correctly only for files of text in English (or another European language), expressed as ASCII bytes. While packing and unpacking a non-ASCII file will not harm it (no information will be lost), the result of packing such a file will not be smaller (and might be larger) than the original file.
Both programs are utilities and have no options. That is, their syntax is
pack infile outfile
unpack infile outfile
For pack, infile is the text file to be compressed and outfile receives the packed data. For unpack, infile is a packed file produced by pack, and outfile is the destination of the restored text. Both programs use the "utility convention"; that is, omitted parts of the outfile filespec will be copied from the infile filespec, and if outfile is omitted, infile will be replaced.
The pack program is essentially a file-copy utility, but it cannot decide what to do with every byte based only on the byte's value. When the current byte is one of the common letters, it must look ahead one more byte to see if it has a compressible pair. This is a simpler problem than that presented by tabbit, where, when we found a blank, we had to look ahead as many as eight bytes before we could be sure whether a blank or a tab should be written.
Since pack looks at only two bytes at a time, it may as well carry two current bytes at all times. In other words, pack can look at its input through a window two bytes wide. When compression is not possible, it will deal only with the byte at the left of the window, then shift the window to the right. When it can compress the byte, it will deal with both bytes, then shift the window to the right twice. We can formalize this notion as a simple plan (Figure 7-1).
b, c and x are bytes
b := next byte
c := next byte
repeat
if (b and c are a compressible pair) then
form the pair as x
write x
b := next byte
c := next byte
else
write b
b := c
c := next byte
endif
until (all bytes processed)
Figure 7-1. A first cut at planning pack.
Before going any further, let's establish the encoding scheme for compressible pairs. We are assuming ASCII input, and that implies that all, or anyway most, input bytes will be less than 128 decimal. Therefore we will reserve all output byte values from 128 to 255 for compression codes. That gives us 128 values to play with.
its high bit set on will represent a compression code.
Now we'll perform one of those shifts of view so common in systems programming. We'll stop thinking of these as numbers, and start thinking of them as the binary values stored in bytes, and specifically as patterns of eight bits. The numbers from 0 to 127 are stored as bit patterns from 0000$0000b through 0111$1111b, while the range we are claiming, 128 to 255, is stored as 1000$0000b through 1111$1111b. (The Digital Research assemblers permit us to scatter dollar-signs freely in numeric constants. Here we are using them to make the groups of bits easier to read.) In other words, the reserved values all have their high (most significant) bit set on. In a byte whose high bit is set, we have seven more bits to use for encoded values.
There are thirteen common characters to look for; it takes four bits to represent thirteen values; therefore four of the seven bits will have to be dedicated to encoding the first letter of the pair. The remaining three bits can represent eight numbers (from zero to seven), and will be used to encode one of eight following characters. A compression code can be described as a binary constant of 1$aaaa$bbb.
The four bits aaaa could be used to encode as many as sixteen first letters. However, once the most common thirteen characters are eliminated, the remaining characters found in English text show a distribution that is nearly flat and that varies significantly from document to document. In other words, the farther down the list we go, the less reliable our predictions about character rank become and therefore the less likely we are to achieve useful compression.
Furthermore, when we restrict the values of the bits aaaa to the range of 0-12, we leave some possible compression codes free for other uses. As things stand, a compression code might range from binary 1$0000$000 to 1$1100$111; that is, from 80h to E7h. The byte values E8h through EFh and F0h to FFh are free, and we can make good use of them for other things.
The output of pack is to consist of uncompressed ASCII bytes (with high bits of zero) and compression codes (with high bits of one). But what shall we do if we find an input byte whose high bit is one? Such bytes are frequent in binary files, and they occur in the document files written by some word processors. They can't be compressed, but if we copy them unchanged unpack will take them to be compression codes.
The solution is to "escape" an offending byte; that is, to prefix it with a special code that says "take the following byte literally." Let's set aside the first of our unused codes, E8h, as a flag for non-ASCII data. When we incorporate that idea into Figure 7-1 we get the plan in Figure 7-2.
b, c and x are bytes
Literally is a constant, E8h
b := next byte
c := next byte
repeat
if (b > 127) then
write Literally
write b
b := c
c := next byte
else if (b and c are a compressible pair) then
form the pair as x
write x
b := next byte
c := next byte
else
write b
b := c
c := next byte
endif
until (all bytes processed)
Figure 7-2. Plan for pack, stage 2.
Take a close look at Figure 7-2. It seems quite redundant. Two branches of the if sequence end in identical statements, and all three end in the same action, c := next byte. That line at least can be factored out and moved to the bottom of the loop. And if the middle branch were to set things up so that b:=c was a proper action, that line could be factored out as well. If we do these things we get the tighter plan shown in Figure 7-3.
b, c and x are bytes
Literally is a constant, E8h
b := next byte
c := next byte
repeat
if (b > 127) then
write Literally
write b
else if (b and c are a compressible pair) then
form the pair as x
write x
c := next byte
else
write b
endif
b := c
c := next byte
until (all bytes processed)
Figure 7-3. Plan for pack after factoring redundant actions.
The plan in Figure 7-3 is well advanced; we had better consider how the I/O actions of "write" and "next byte" will be implemented. When we specified these programs, we promised that they would be able to handle binary files without losing any information. That promise may cause problems. It means that "next byte" will have to be implemented with the non-ASCII input function, fgetbyte. There are several implications.
When we deal only with text data, we can use the ASCII function fgetchar and so receive two benefits. That function swallows a linefeed that follows a return, so that end of line is marked by a single byte rather than the return-linefeed pair that actually end a line of text. Pack, however, will be seeing both characters when it reads a text file.
Detection of end of file is more complicated with fgetbyte. The logical end of a text file is marked by a special character, control-Z (equated to CpmEof in the listings). There is no such convenient marker in a binary file; any byte value is possible, so none of them can be reserved to mark end of file. When fgetbyte finds there is no more data in the file, it reports back with the Zero flag true and a byte value of zero, but zero is also possible as data.
Consider Figure 7-3 in the light of these facts. How will we be able to implement the loop test, "until all bytes processed"? End of file could be discovered at any of the places where the program reads the next byte. There are two of these at the moment. If the last two bytes of a file form a compressible pair, end of file will be seen in the middle of the loop and again at the bottom, and all bytes will have been processed. If not, end of file will be seen first at the bottom of the loop, at which time the byte in b will remain to be processed. It would be decidedly awkward to figure out which condition obtains and to clean up the remaining byte in the second case. It could be done (for instance, by making "next byte" into a subroutine and in it, counting the number of times end of file is reported) but the resulting plan is not pretty.
Things would be ever so much simpler if there was a data value that meant end of file, as there is in text files. Then, so long as we made sure that that data value wouldn't participate in any compressions, the loop condition could be stated as until (b = end of file value). In other words, the loop could be allowed to run until that value had worked its way across the two-byte window, at which time all the preceding data bytes would have been dealt with.
It can be done. In the C programming language, it is done by treating byte values as integer values, and by using an integer value greater than 255 to mean end of file. Doing this will complicate the implementation (it will mean devoting a register pair to the variables b and c), but it will simplify our design work a good deal. (I know that because I've already gone through several abortive designs that didn't use this dodge.)
The pack program will find end of file at the physical end of its input file. But it will write less data than it reads, so its output file may not end exactly on a CP/M record boundary. The toolkit functions will fill out the last record with meaningless bytes, and we won't want unpack to process these. So pack will have to flag the end of the meaningful data with something that unpack can recognize. Let's assign the unused compression code EFh for that purpose -- its hex value looks like an acronym for End File -- and make sure that pack writes that as the last byte of compressed data.
The plan so far will compress certain pairs of lowercase letters, but other common pairs occur in CP/M text files. If we can identify them, we have the compression codes E9h to EEh to represent them.
One such pair is the CR-LF combination -- the carriage return and linefeed bytes that end every line of a text file. Pack will be seeing these and it could recognize them and translate them to a single compression code.
I can think of only one other combination that is likely to be generally useful. That's the combination CR-LF-TAB, which begins so many lines of program source files in almost any language. Many other combinations could be cited as common, but only in specific kinds of files: the CR-LF-semicolon that is common in assembly-language files; the zero-zero, comma-zero and space-zero pairs often found in data files; and so on. But all such compressions are specific to one kind of data and would have little effect on any other kind of file.
When we insert these two ad-hoc compressions into the plan, and extend it to account for the fact that bytes will be handled as integers, we get the plan shown in Figure 7-4.
b and c are words
x is a byte
Literally is a constant, E8h
CRLF is a constant, E9h
CLTAB is a constant, EAh
EndFile is a constant, EFh
readone() : returns a word
if (end of file) then return 0100h
else return fgetbyte(infile)
end readone
compare(a,w) : returns true/false
if (w > 255) then return false g
if (a = w) then return true
else return false
end compare
b := readone()
c := readone()
repeat
if (b > 127) then
fputbyte(Literally,outfile)
fputbyte(b,outfile)
else if (b and c are a compressible pair) then
form the pair as x
fputbyte(x,outfile)
c := readone()
else if (compare(CR,b) and (compare(LF,c) then
x := CRLF
c := readone()
if (compare(TAB,c) then
x := CLTAB
c := readone()
endif
fputbyte(x,outfile)
else
fputbyte(b,outfile)
endif
b := c
c := readone()
until (b > 255)
Figure 7-4. Plan for pack with ad-hoc compressions.
Runs of identical characters aren't common in document files, but they do occur; and they are often found in program source files. It won't hurt the effectiveness of pack to attempt run-length encoding and it might help it. We have the sixteen compression codes F0h-FFh; let's use them. We shall specify that a run of identical characters is represented by a two-byte code Fnh, cch, where the hex digit n is the number of times that the byte cch should be repeated. The shortest possible run is two bytes long, so let us say that the compression code Fnh means that the following byte should be repeated n+2 times. F0h means a run of 2, F2h a run of 4, and so on to FFh, signifying a run of 17 bytes.
In order to recognize a run longer than two bytes we will have to look further ahead in the file than our two-byte window allows. We won't even try unless all other compressions have failed and b=c; that is, when we have a run of at least two bytes that are otherwise incompressible.
Once we see that condition, we will enter a loop in which we will suck up as many more identical bytes as possible, just as in tabbit we vacumed up as many blanks as we could find before writing any tabs. Having counted the identical characters we will be able to write as many run codes as the count allows. If the count is odd, we'll have to write a solo byte to make up the final tally. The plan for this is in Figure 7-5. Imagine that code inserted into Figure 7-4 just preceding the final else.
else if (b = c) then
n := 2
loop
c := readone
while (c < 256) and (b = c)
n := n + 1
end loop
while (n >= 17) do
fputbyte(FFh,outfile)
fputbyte(b,outfile)
n := n-17
end while
if (n > 0) then
if (n > 1) then
x := F0h + n-2
fputbyte(x,outfile)
endif
fputbyte(b,outfile)
endif
Figure 7-5. Plan for doing run-length encoding in pack.
There is a new pseudo-code structure in Figure 7-5, the loop structure. No common programming languages support the loop structure, yet it is often useful, especially in assembly language programming. It signifies a middle-exit loop. In general form it is
loop
first act
while (condition)
second act
end loop
Here is the sense of it: do the first act; test the condition; if it is true then do the second act and repeat the loop; otherwise exit the loop. There is an alternative way of writing the same thing:
first act
while (condition)
second act
first act
end while
In Figure 7-5, first act is c := readone() and second act is n := n+1. Write the loop from Figure 7-5 in while form and compare the two. Do you agree that they describe the same sequence of actions? The loop structure rotates the while ninety degrees, so to speak, and thus does away with the redundant statement of first act.
There's still a vague line in Figure 7-5. We have been putting off a decision on how to implement the test and action
if (b and c are a compressible pair) then
form the pair as x
Making the test will involve making separate tests on b and c. Is b one of the thirteen common characters? If so, is c one of its common followers? If so, the numbers that will be combined in the pair-code should be produced as a byproduct of making the tests.
Therefore let's specify a subroutine that will ask the questions, form a compression code if it can, or form something harmless if it can't. Then we can test its output. In other words, we will be able to write
if (x := makepair(b,c) produces a code) then
This trick of having a function serve two purposes -- returning a usable value when a condition is true and returning a harmless signal when it isn't -- is often useful at the pseudo-code level. As we will discover, it sometimes doesn't work out so well at the level of assembly language statements.
How shall makepair do its work? It should look up the first character in a list of common characters, quitting with a non-code if it isn't there or saving the character's rank-number if it is. Then it can select a list of possible followers based on the rank-number, and search for the second character in that list. (These follower-lists are the rows of u.) A plan along these lines is shown in Figure 7-6.
list1 is a string "(space)etaoinshrdlu"
followers is an array of strings -- see u for contents
makepair(b,c) returns a byte x
p and q are bytes
p := lookup(b,list1)
if (p > 12) then return 0
q := lookup(c,followers[p])
if (q > 7) then return 0
return 80h + (p shiftleft 3) + q
end makepair
Figure 7-6. Plan for the subroutine makepair.
As displayed, makepair depends on lookup, a subroutine that will search for a character within a string and return its index counting from zero. Strangely enough, there is no such function in the toolkit. It isn't hard to write such a function; we can defer it until we code the program.
The outer portions of pack.asm are shown in Figure 7-7. Much of this material should be familiar from the utilities we've done before. I thought it all would be, and so prepared no specific plan for for it. However, a new consideration cropped up.
;===============================================================
; PACK infile outfile
; Read infile, compress using digraph and run-length methods,
; and write compressed data as outfile.
; Abort messages:
; file-open messages
; usage message
; Modules
; CHKOPS
; OPENUT (OPENIN, OPENOU)
; LOOKUP
; History:
; initial code 13 July 84
;===============================================================
maclib environ
dseg
usage:
db AsciiTAB,'PACK infile outfile'
db AsciiCR,AsciiLF
db 'Reads file "infile," compresses its data, and writes the'
db AsciiCR,AsciiLF
db 'compressed result as "outfile." Use the UNPACK program'
db AsciiCR,AsciiLF
db 'to restore packed data to normal. Only ASCII files will'
db AsciiCR,AsciiLF
db 'show significant reduction in size.'
db AsciiCR,AsciiLF
db '$'
infile: filedef 128 ; small input buffer
outfile: filedef 16384 ; big buffer for speed
Literally equ 0E8h ; prefix for nonascii byte
CRLF equ 0E9h ; code for CR, LF pair
CLTAB equ 0EAh ; code for CR, LF, TAB
; EBh through EEh uncommitted
EndFile equ 0EFh ; code for physical end of file
RunBase equ 0F0h ; base code for a run of 2 or more
Run17 equ 0FFh ; code for a run of 17
Verflag equ 0FFh ; prefix for version
Version equ 74h ; month/year in hex for version check
cseg
extrn CHKOPS,OPENIN,OPENOU
prolog
; chkops(1,2)
mvi b,1 ; at least one,
mvi c,2 ; not more than 2, operands
lxi d,usage
call CHKOPS
; openin(1,infile)
mvi a,1
lxi d,infile
call OPENIN
; openou(2,outfile,infile)
mvi a,2
lxi b,infile
lxi d,outfile
call OPENOU
; initialize outfile with version-code
mvi a,Verflag
fputbyte +A
mvi a,Version
fputbyte +A
call process ; process-loop out of line
fclose outfile
ret
Figure 7-7. The initialization code of pack.
It occurred to me that if unpack tried to restore a file that hadn't been packed in the first place it could produce rubbish. It shouldn't accept any that hadn't been prepared by pack. In order that it would be able to recognize such files, I made pack start its output file with a two-byte "version code" that unpack can check for.
The first byte of the version code is FFh, a value that is not likely to be the first byte of an unpacked file. It certainly isn't likely in a text file, nor in a program object file (where it would represent a reset instruction as the first statement of a program). The second byte is an arbitrary choice.
I prepared the first version of pack in the same way that I did previous programs -- by editing the pseudo-code file and inserting assembly statements to implement the pseudo-code actions. The program worked, but it wasn't fast enough to suit me.
We can judge the performance of a utility by the rate at which it consumes input data. I tested pack on a file of 62,000 bytes (performance timings should always be made with large files in order to swamp the time taken to load the program and open its files). It took 47 seconds to process the file. That's an input rate of only 1300 bytes per second, or a processing time of about 760 microseconds per byte. That would be a respectable data rate if the program had been written in a high-level language, but speed is the whole point of using assembly language.
Since the test was made on a machine with large, fast disks, I knew it would be even slower in a system with 5-inch diskette drives, and those are just the machines where pack should be most useful. So I went looking for the place where too much time was being spent.
The first question was, how fast should the program be? To find out, I modified it so that it did almost no processing at all. It was compressing data in a 3:2 ratio; that is, it was writing two bytes for every three it read. I edited the main repeat-until loop so that it did nothing but read three bytes and write two. This gutted version of the program processed (so to speak) at a rate of 1880 bytes per second or 530 microseconds/byte -- a 30% improvement in speed, if not in function.
The next questions were, what part of the main loop was eating up more than 200 microseconds for every input byte, and could it be streamlined? Z80 instructions take from one to three microseconds each. Roughly speaking, that 200 microsecond bulge represented 100 instructions on each trip 'round the repeat loop. Most of the loop consisted of tests and branches. The special cases of carriage returns and of bytes greater than 127, for example, were dealt with in a couple of dozen instructions. Nor could the problem be related to run-length encoding, since the test file had few if any runs.
That left only the implementation of makepair. Its logic and use followed closely after Figure 7-5. It was called for every ASCII byte except a CR; it used lookup to find out if the byte was in the common set; if so, it used lookup once more to check the following letter.
It was clearly inefficient to look up every byte in the list of common letters. Many of them wouldn't be there, and the search took the longest time to return a negative result.
I inserted code so that makepair was called only when the current byte was a space or a lowercase letter between a and u inclusive. In pseudo-code terms, I changed the test
if (x := makepair produces a code)
to the more involved test
if (b = space) or ((b >= 'a') and (b <= 'u')) then
if (x := makepair produces a code)
This change improved the program; it now processed at 1450 bytes/second (690 microseconds/byte). But that amounted to a change of only 10% and I wanted something closer to 25%. To get it, I would have to depart further from the pseudo-code.
It occurred to me that the current byte could be classified through a table look-up rather than a sequential search of a list. I prepared a table having 128 entries. All of the entries contained FFh except the ones that corresponded to the common thirteen; those contained the numbers 0-12 that would encode them. It takes only a few instructions to index the table; the number extracted from it is supplies both the signal that the byte is or is not one of the common ones and its code value when it is.
This version of the main loop appears in Figure 7-8. It consumed input data at 1690 bytes/second, 20% faster than the original implementation of the pseudo-code. However, the pseudo-code plan served an essential function. It made the first implementation of pack easy to build, and that version verified the algorithm. The performance improvements followed, each a small change on a program that was known to be working. This is an application of the motto, "first make it right, then make it fast."
;===============================================================
; In the following we will deal with pseudo-code variables b and
; c as words, and carry them in registers BC and DE respectively
; To reduce (?) confusion we give meaningful (?) names to the
; high and low bytes of the register pairs.
varBhi equ B
varBlo equ C
varChi equ D
varClo equ E
;===============================================================
; readtoC(): read a byte from infile and form it as a word in
; the variable c. Form a value of 256 if end of file is seen.
readtoC:
fgetbyte infile
mov varClo,a
mvi varChi,0 ; assume not end of file
rnz ; (right)
inr varChi ; 00 returned, make c=0100h
ret
;===============================================================
; compareC(A): compare the byte in A to variable c. If c>255
; then it doesn't match anything; otherwise do a byte compare.
compareC:
dcr varChi ; test hi byte for zero by
inr varChi ; decrementing and incrementing
rnz ; c > 255, is eof, matches nothing
cmp varClo
ret
;===============================================================
; cmpBC: compare b=c. If either is > 255 (end of file), they
; don't match. Else do byte-compare.
cmpBC:
mov a,varBhi
ora varChi
rnz ; (exit if either > 255)
mov a,varBlo
cmp varClo
ret
;===============================================================
; the main loop, after the pseudo-code but modified to use the
; above hardware-specific subroutines. A classification table
; is used for quick recognition and translation of the common
; 13 characters " etaoinshrdlu."
dseg
classtable:
$-PRINT ; shorten the listing
rept 128 ; array[0..127] of bytes
db 0ffh ; = not one of the common characters
endm
org classtable+' '
db 0
org classtable+'e'
db 1
org classtable+'t'
db 2
(etc., etc., up to...)
org classtable+'u'
db 12
org classtable+128
cseg
process:
push psw
push b
push d
push h
;b := readone()
;c := readone()
call readtoC
mov varBhi,varChi
mov varBlo,varClo
call readtoC
;repeat
toploop:
; if (b > 127) then
; fputbyte(Literally,outfile)
; fputbyte(b,outfile)
mov a,varBlo ; if byte value
ora a ; ..has high bit of 0
jp lt128 ; ..it is less than 128
mvi a,Literally
fputbyte outfile,+A
mov a,varBlo
fputbyte outfile,+A
jmp botloop
lt128:
; else if (b and c are a compressible pair) then
; form the pair as x
lxi h,classtable
dad b ; HL->classtable[b]
mov a,m ; ..which is FF
ora a ; ..if b not one of " etaoinshrdlu"
jm nopair
call makepair ; try 2nd byte for a pair
ora a ; paircodes have high bit set
jp nopair
; fputbyte(x,outfile)
; c := readone()
fputbyte outfile,+A
call readtoC
jmp botloop
nopair:
; else if (b = CR) and (c = LF) then
mov a,varBlo
cpi AsciiCR
jnz noCR
mvi a,AsciiLF
call compareC
jnz noCR
; x := CRLF
; c := readone()
mvi h,CRLF ; use H for "x" here
call readtoC
; if (c = TAB) then
mvi a,AsciiTAB
call compareC
jnz noTAB
; x := CLTAB
; c := readone(infile)
mvi h,CLTAB
call readtoC
noTAB:
; endif
; fputbyte(x,outfile)
mov a,h
fputbyte outfile,+A
jmp botloop
noCR:
; else if (b = c) then
call cmpbc
jnz norun
call doruncode ; run-code stuff out of line
jmp botloop
norun:
; else
; fputbyte(b,outfile)
mov a,varBlo
fputbyte outfile,+A
; endif
botloop:
; b := c
mov varBhi,varChi
mov varBlo,varClo
; c := readone(infile)
call readtoC
;until (b > 255)
mov a,varBhi
ora a
jz toploop
mvi a,EndFile
fputbyte outfile,+A
pop h
pop d
pop b
pop psw
ret
Figure 7-8. The main loop of pack after performance modifications.
Byte values are easy to manipulate in assembly language, word values less so. Still, the need to carry the pseudo-code variables b and c as words, not bytes, caused less trouble than I'd expected. One fact helped. If you study Figure 7-4 you will see that, throughout the body of the loop, the value in b must be a data byte; it cannot be the end of file value. All input goes to c; only at the bottom of the loop is c copied to b. Immediately afterward the loop condition is evaluated, and execution can continue only if b is still a data value.
That fact made it possible to treat the variable b as a byte at most points; only the variable c had to be handled as if it might contain the end of file signal at any time.
The efficiency changes in the main loop caused changes in makepair as well. It would only be called when b was known to be one of the common letters, and it could be passed the number that encoded that letter. Its remaining job was to check the c byte to see if it was a common follower of b, and to prepare the pair-code if so.
I prepared thirteen strings of follower characters -- the rows of u -- and a separate table of their addresses. Twice the first letter's code value, plus the address of the table, yielded the address of the proper list of followers. Since the pseudo-code function lookup was now used in only one place, I made it in-line code. The final code appears in Figure 7-9.
dseg
aftersp strconst 't aiocsw'
aftere strconst ' rsndcmt'
aftert strconst 'h eorias'
aftera strconst 'nt lrmcs'
aftero strconst ' nrfupmd'
afteri strconst 'ntsclofg'
aftern strconst ' dtgeaso'
afters strconst ' tesia.u'
afterh strconst 'ea iotr.'
afterr strconst 'ea oisty'
afterd strconst ' eios.a,'
afterl strconst 'e lioydv'
afteru strconst 'tslrmned'
followers: ; table of pointers to follower-lists
dw aftersp
dw aftere,aftert,aftera
dw aftero,afteri,aftern
dw afters,afterh,afterr
dw afterd,afterl,afteru
cseg
makepair:
dcr varChi
inr varChi ; if c > 255,
rnz ; ..we can't make a pair
push b
push d
push h
; At this point we know variable c is a byte, not eof, and
; that variable b -- regs BC -- is too. Hence BC=00xx.
; We'll be using B and C as work registers, and will rely
; on B being initially 00.
; p := lookup(b,list1) -- already done
mov c,a ; BC = 00nn, the first number
; q := lookup(c,followers[p])
lxi h,followers
dad b ; add twice 00nn to HL
dad b ; HL->(word)->followers of "b"
mov a,m
inx h
mov h,m
mov l,a ; HL->followers of "b"
makeloop:
mov a,m ; A = possible follower
cmp varClo ; is this it?
jz makepool; (yes)
inr b ; no, increment "q"
inx h ; ..and point to next candidate
ora a ; was that 00 = end of list?
jnz makeloop; (no, continue)
makepool:
; if (q > 7) then return 0
mov a,b
cpi 8 ; did we find c in follower-list?
jnc makedone; (no, exit with A not a pair code)
; return 80h + (p shiftleft 3) + q
mov a,c
add a ! add a ! add a
ora b
ori 80h
makedone:
;end makepair
pop h
pop d
pop b
ret
Figure 7-9. The implementation of makepair.
The implementation of run-length encoding followed very closely on the pseudo-code of Figure 7-5; it appears in Figure 7-10. The only complication came from my realization that it was just barely possible that pack might encounter a run of more than 256 identical bytes. That meant that the count of identical bytes had to be handled as a word, not a byte. This caused no problem while counting up, but it meant that a 16-bit subtract would have to be used while counting down by 17. Once the count has been reduced below 17, however, it may be handled as a byte.
;===============================================================
; doruncode: the logic for forming a run-code, after the pseudo-
; code but moved out of line for clarity. On arrival, we know
; that b=c and therefore c isn't end of file. Regs HL are used
; for variable "n" at first, since we could count a run of more
; than 256 bytes.
doruncode:
push psw
push h
; n := 2
lxi h,2
; loop
runloop:
; c := readone
call readtoC
; while (c < 256) and (b = c)
call cmpBC ; checks both conditions
jnz runover
; n := n + 1
inx h
jmp runloop
runover:
; end loop
; while (n >= 17) do
; fputbyte(FFh,outfile)
; fputbyte(b,outfile)
; n := n-17
; end while
push d ; save DE over loop
lxi d,17 ; ..and carry 17 in it
putloop:
ora a ; (clear carry)
dsbc d ; decrement n by 17
jc putover ; ..and stop if n < 17
mvi a,Run17
fputbyte outfile,+A
mov a,varBlo
fputbyte outfile,+A
jmp putloop
putover:
mov a,l ; A now has residual "n"
add e ; put back the 17-too-much
pop d ; and restore DE
; if (n > 0) then
jz rundone
; if (n > 1) then
cpi 2
jc runsolo
; x := F0h + n-2
; fputbyte(x,outfile)
sbi 2
ori RunBase
fputbyte outfile,+A
; endif
runsolo:
; fputbyte(b,outfile)
mov a,varBlo
fputbyte outfile,+A
; endif
rundone:
pop h
pop psw
ret
Figure 7-10. The implementation of run-length coding.
The pack program reduces the size of most text files by a third, rendering them useless as it does so. It is time to design the program that will restore a packed file to its original form.
The input to unpack is the sequence of bytes that pack wrote. We can list them all; they include
The program can decide what to do next on the basis of a single byte's value. When a program is to select one of several possible actions based on a single value, we can express its shape with a pseudo-code case structure, as shown in Figure 7-11.
next is a byte
next := fgetbyte(infile)
repeat
case (next) of
i 0..7Fh:
fputbyte(next,outfile)
s 80..E7h:
decode the pair as b,c
fputbyte(b,outfile)
fputbyte(c,outfile)
i E8h:
next := fgetbyte(infile)
fputbyte(next,outfile)
F E9h:
fputbyte(CR,outfile)
fputbyte(LF,outfile)
B EAh:
fputbyte(CR,outfile)
fputbyte(LF,outfile)
fputbyte(TAB,outfile)
e F0..FFh:
n := 2 + low four bits of next
next := fgetbyte(infile)
do n times: fputbyte(next,outfile)
end case.
next := fgetbyte(infile)
until (next = EFh)
Figure 7-11. The main loop of unpack, expressed as a case structure.
If you know the Pascal language, you'll recognize the case structure. Even if you don't, its meaning should be clear. The line "case (next) of" means "based on the value of next, select one of the following actions." The action-groups follow, each prefixed by the value or range of values to which it applies.
We can implement a case with a mechanism very much like the character-class table used in pack. The program can classify the current byte by indexing a table; the class-value produced is an index of some sort to the action that should be performed on that byte.
The only thing that isn't detailed in Figure 7-11 is the action for a pair-code, "decode the pair as b, c." It shouldn't be hard to implement, however. Each pair-code consists of two binary numbers, one of four bits and one of three. The first, four-bit, number stands for the first character of the pair. It also stands for the row of u (thirteen times eight) are laid out in row order in storage, the seven-bit concatenation of the two numbers will lead us directly to the correct second character!
The only complicated part of implementing unpack was the implementation of the case statement. It is possible to do this in assembly language in a way that is very fast, but it requires subtle uses of the assembler.
Look back at Figure 7-11. Each action within the case structure is independent; when it completes, control should pass to the end of the entire structure. The obvious way to implement it is to write a label at the head of each action, put a label at the end of the structure, and end each action with a jump to the end.
case1:
action 1
jmp caseend
case2:
action 2
jmp caseend
The only remaining problem is how to select, and jump to, the correct label.
There are several ways to do it. Here's the way I chose to do it for unpack, where the case label is to be selected based on the value of an input byte. I placed a label at the top of the structure. The difference between a case label and the top label, that is, the result of an assembler expression like
case2 - casetop
is the byte offset of that case label within the case structure. If we construct a table with one entry for each possible input byte, and place in that entry the offset to the case that handles that byte, we can select the case very quickly. All we need to do is use the input byte as an index to the table, add the resulting offset to the label of the case structure as a whole, and we have the address of the needed case. The pchl instruction lets us jump directly to that address.
In unpack, all the cases fit within 256 bytes, so each case's offset fit in a byte. Input bytes might have any of 256 values, so the case-table had to have 256 entries, each containing the offset to the relevant case.
It would be incredibly tedious to type 256 table entries of the form
db casex-casetop
There are only six different cases, and three of them account for most of the entries. These three cover ranges of consecutive input values. Most of the table can be described as 128 repetitions of the ASCII case, 104 repetitions of the pair-code case, and 16 repetitions of the run-code case.
The mac and rmac assemblers contain a command that is just what we need. The sequence
rept count
statements
endm
produces count repetitions of the statements. The expression
0E7h-080h+1
yields the count of possible pair-codes, so the sequence
rept 0E7h-080h+1
db casepair-casetop
endm
will generate all those entries of the table. The rest of the table can be produced in the same way, as you can see in Figure 7-12.
;===============================================================
; UNPACK infile outfile
; Read a file created by PACK and restore it to its original
; condition.
; Abort messages:
; file-open messages
; usage message
; "Input file was not created by PACK."
; "Impossible input value -- error in PACK."
; Modules
; CHKOPS
; OPENUT (OPENIN, OPENOU)
; History:
; initial code 17 July 84
;===============================================================
maclib environ
dseg
usage:
db AsciiTAB,'UNPACK infile outfile'
db AsciiCR,AsciiLF
db 'Input file "infile" must have been created by the PACK'
db AsciiCR,AsciiLF
db 'program. Its contents are restored to their original'
db AsciiCR,AsciiLF
db 'form and written as "outfile."'
db AsciiCR,AsciiLF
db '$'
notpacked:
db 'Input file not prepared by PACK.$'
impossible:
db 'Impossible input value -- error in PACK.$'
infile: filedef 128 ; small input buffer
outfile: filedef 16384 ; big buffer for speed
Literally equ 0E8h ; prefix for nonascii byte
CRLF equ 0E9h ; code for CR, LF pair
CRLFTAB equ 0EAh ; code for CR, LF, TAB
; EBh through EEh uncommitted
EndFlag equ 0EFh ; code for physical end of file
RunBase equ 0F0h ; base code for a run of 2 or more
Run17 equ 0FFh ; code for a run of 17
Verflag equ 0FFh ; prefix for version
Version equ 74h ; month/year in hex for version check
cseg
extrn CHKOPS,OPENIN,OPENOU
prolog
; chkops(1,2)
mvi b,1 ; at least one,
mvi c,2 ; not more than 2, operands
lxi d,usage
call CHKOPS
; openin(1,infile)
mvi a,1
lxi d,infile
call OPENIN
; openou(2,outfile,infile)
mvi a,2
lxi b,infile
lxi d,outfile
call OPENOU
; check for the version code
lxi d,infile
fgetbyte +D
cpi VerFlag
abort nz,notpacked
fgetbyte +D
cpi Version
abort nz,notpacked
call process ; process-loop out of line
fclose outfile
ret
;===============================================================
; process: the main loop, from the pseudo-code plan. The case
; structure is implemented using a table that contains one entry
; for each possible input byte. Each entry is the offset from
; "casetop" to the "case---" label appropriate to that byte.
dseg
casetable:
$-PRINT ; shorten the listing
; note: the following uses of REPT revealed a bug in RMAC 1.1.
; If the repetition value is between 61h and 7Ah inclusive, REPT
; performs 20h too few repetitions (i.e. 41h to 5Ah). The IF
; statement after the REPT checks for this and compensates.
count set 1+07Fh-0 ; 0..7f = ascii characters
rept count
db toascii ; "to..." labels defined later
endm
count set 1+0E7h-80h ; 80..E7 = pair codes
temp set $
rept count
db topair
endm
if ($-temp) lt count ; then we didn't get enough repeats
rept count-($-temp) ; make up the lack
db topair
endm
endif
db tononasc ; E8 = nonascii follows
db tocrlf ; E9 = CR, LF
db tocltab ; EA = CR, LF, TAB
rept 1+0EEh-0EBh ; EB..EF = impossible
db toerror
endm
db toerror ; EF = endfile shouldn't come here
rept 1+0FFh-0F0h ; F0..FF = runcodes
db torun
endm
cseg
process:
push psw
push b
push d
push h
; next := fgetbyte(infile)
lxi d,infile
fgetbyte +D
mov b,a ; carry "next" in B
lxi d,outfile ; carry DE->outfile
;repeat
toploop:
; case (next) of
lxi h,casetable
mov a,b
addhla ; HL -> class of byte in A
mov a,m ; get it to A
lxi h,cases
addhla ; HL-> code of case action
pchl ; go do it
cases:
;i 0..7Fh : fputbyte(next,outfile)
caseascii:
toascii equ caseascii-cases
mov a,b
fputbyte +A
jmp casend
;s 80..E7h: decode the pair as b,c
; fputbyte(b,outfile)
; fputbyte(c,outfile)
casepair:
topair equ casepair-cases
mov a,b
call decode ; decode A to B,C
mov a,b
fputbyte +A
mov a,c
fputbyte +A
jmp casend
;i E8h : next := fgetbyte(infile)
; fputbyte(next,outfile)
casenonasc:
tononasc equ casenonasc-cases
fgetbyte infile
fputbyte +A
jmp casend
;F E9h : fputbyte(CR,outfile)
; fputbyte(LF,outfile)
casecrlf:
tocrlf equ casecrlf-cases
mvi a,AsciiCR
fputbyte +A
mvi a,AsciiLF
fputbyte +A
jmp casend
;B EAh : fputbyte(CR,outfile)
; fputbyte(LF,outfile)
; fputbyte(TAB,outfile)
casecltab:
tocltab equ casecltab-cases
mvi a,AsciiCR
fputbyte +A
mvi a,AsciiLF
fputbyte +A
mvi a,AsciiTAB
fputbyte +A
jmp casend
;e F0..FFh: n := 2 + low four bits of next
; next := fgetbyte(infile)
; do n times: fputbyte(next,outfile)
caserun:
torun equ caserun-cases
mov a,b
ani 0Fh ; A = low bits of runcode
adi 2 ; ..plus 2
mov b,a ; save loop count in B
fgetbyte infile
running:
fputbyte +A
djnz running
jmp casend
; the "impossible" input is also a case
caserror:
toerror equ caserror-cases
abort ,impossible
; end case.
casend:
; next := fgetbyte(infile)
fgetbyte infile
mov b,a
;until (next = EFh)
cpi EndFlag
jnz toploop
pop h
pop d
pop b
pop psw
ret
;===============================================================
; decode(paircode in A to characters in B, C) -- decoding is
; done by using the values in the paircode to index tables of
; letters. The code is 1aaaabbb. The first letter of the pair
; is firstab[aaaa] while the second is followtab[aaaa,bbb].
dseg
firstab:
db ' etaoinshrdlu'
followtab:
db 't aiocsw'
db ' rsndcmt'
db 'h eorias'
db 'nt lrmcs'
db ' nrfupmd'
db 'ntsclofg'
db ' dtgeaso'
db ' tesia.u'
db 'ea iotr.'
db 'ea oisty'
db ' eios.a,'
db 'e lioydv'
db 'tslrmned'
cseg
decode:
push h
push psw ; save copy of paircode
; get the 4-bit number "aaaa" to the low four bits of the
; register and isolate it.
rar ! rar ! rar
ani 0Fh
; use it to select the first letter
lxi h,firstab
addhla
mov b,m
; use the whole 7-bits aaaabbb to index the 2nd letter
pop psw
ani 7Fh ; get rid of high bit
lxi h,followtab
addhla
mov c,m
pop h
ret
Figure 7-12. The assembly implementation of unpack.
This use of rept turned up a bug in rmac version 1.1. When I tested unpack, it crashed my system. I ran it under the sid debugging tool several times and finally determined that it crashed while trying to handle the CR-LF code. By stepping it through the case loop I found it was loading an invalid case-offset from the table. I supposed that I must have written one of the repeat-count expressions incorrectly, so I displayed the table and counted its entries. There were too few entries for pair-codes. That made the whole table too short, so that what should have been the entry for the CR-LF code actually fell into a table of ASCII characters. That accidental byte, treated as a case-offset, sent the program off to who-knows-where.
Yet on inspection all the count expressions proved correct. It turned out that the rept command itself was at fault; it repeated 32 times too few for a certain range of counts. What to do? I didn't want to code the table by hand; yet if I stuck in an extra rept to make up the difference, the table would come out too big when assembled by an assembler that lacked the bug. I finally repaired the damage by adding an assembler if statement. It checks to see if the right number of bytes were generated and, if they weren't, executes another rept to make up the difference.
Utility programs, because they're usually simple, used a lot, and need to run fast, are ideal places to apply assembly language. Pack and unpack are good examples. They compress text files at least as well as the (more complicated and slower) public domain squeeze programs, and they run with pleasant speed. But notice that the speed didn't come automatically, just because we used assembly language. The first version of pack was slower than desirable, and it took changes in both the algorithm and the way the algorithm was implemented to speed it up.
With the programs of these two chapters for models, you should be able to construct a variety of utility programs. In the next chapter we will start work on a project of a higher order of difficulty
In this chapter we will begin a major project: the management of "anthologies," large files in which smaller files are collected. The project will require a suite of related programs, several common modules, the use of direct-access files, and advanced uses of strings, files, and dynamic storage. We'll design it in this chapter, and implement it in the next two.
The CP/M file system is flexible and reliable, but it has some shortcomings. These are most evident when it is used to store small files on disk drives of small capacity.
The first problem is that CP/M allocates disk space in fixed units of at least one kilobyte. When a file's size is not an exact multiple of kilobytes, CP/M wastes some space. Half a kilobyte is lost for each file on a disk, on the average. When a disk holds only a few large files this lost space is a tiny percentage of the space in use, but when there are many small files the aggregate loss can add up to a noticeable fraction of the disk's capacity.
The second, and worse, problem is that all files must be recorded in the directory of a disk, and the directory has a fixed size. Every file requires at least one directory entry; larger files may take up two or more. Again, this causes no difficulty when the disk holds a few large files. It's a different story when one wants to store a large number of small files (all the assembly source files of the toolkit modules, for instance). Then it's quite possible to fill up the directory of the disk before the disk runs out of data space.
Both problems are compounded when the disks are small. A TRS-80 Model 4 with CP/M Plus, for example, can store only 150 kilobytes on a disk, and its disk directories allow only 64 entries. That means it can store no more than 64 files, no matter how small the files are. When it puts 32 files on a disk, it will create about 16 kilobytes of allocated but unused space -- 10% of the disk's capacity. An Apple IIe with CP/M Plus has even less capacity, with 48 entries in the directory and 128 kilobytes per disk.
There is no way to avoid these problems during normal use of a system. The command programs and the files they work on must be stored by CP/M in its usual way; everything in the system depends on it. But the problems can be eased in the special case of files that are not used often. It is possible to collect many small, low-use files and pack them into a single file. When small files are packed into a large one, the only wasted space is a single chunk at the end of the collection, while the collection requires only a few directory entries no matter how many files are in it. As many as a hundred small files could be kept in such a collection, and the collection might still fit on a small diskette.
When many small literary works are collected in a single volume, the book is called an anthology. That's the term that I've adopted for files that hold many smaller files. A more common term for them is "library"; on mainframe systems they are sometimes called "partitioned datasets."
The idea of anthologies is not new. In fact, we have been dealing with two versions of the idea right along. A macro library is an anthology of macros, and an object library as built by the lib command is an anthology of object files. Gary Novosielski's Library Utility established a standard for the format of anthologies of public-domain software.
It is possible to create an anthology system of sorts using nothing more than the pip and submit commands. The pip command can concatenate files, and it can extract a part of a file with its s (start) and q (quit) options. These are the essential elements of anthologizing -- the ability to put two files together, and the ability to pull out a portion of the combined file.
You might enjoy implementing a pip-based anthology system; if you do, you'll learn a lot about the design of anthologies and about the CP/M commands. The submit files in Figure 8-1 are a beginning. The first one (shown in two versions) adds a file to an anthology. It prefixes the file with a delimiting line composed of %%% and the filespec, then puts that line and the file at the head of the anthology.
; add file $2 to anthology $1.ANT (CP/M 2.2 version)
xsub
pip $1.ANT=CON:,$2,$1.ANT
%%%$2
^Z
; add file $2 to anthology $1.ANT (CP/M Plus version)
pip $1.ANT=CON:,$2,$1.ANT
<%%%$2
<^Z
; get file $2 from anthology $1.ANT
pip $2=$1.ANT[ s%%%$2^Z q%%%^Z ]
Figure 8-1. Batch files to implement a simple anthology system.
The second submit file uses the start and quit options of pip to retrieve one file. It presumes that every member of the anthology is followed by a delimiter line. That will be true of every member except the last one (the first one added). For it to be true of that member as well, the initial anthology file must be created with a single delimiter line. A submit file that will create an anthology is easy to write.
The pip command isn't sophisticated enough to allow deleting members from an anthology, nor can it be made to list the members in one. However, the CP/M editor ed can be driven from a submit file. It is possible to a build a fairly complete set of anthology commands using ed, pip, and submit. I made one, but I wasn't able to find a way to make it detect errors. It would add duplicate members, crash when asked to retrieve a nonexistent member, and try to add the anthology to itself if I told it to. And it was painfully slow.
Anthology programs can be found in collections of public-domain software. Why, then are we going to build our own? For two reasons: because I didn't care for the existing ones I tried (in particular, I thought they didn't pay enough attention to data integrity); and because the project is a good vehicle for demonstrating the tools and techniques developed in this book at the full stretch of their capacities.
As usual, our first step in building a program is to specify its external appearance. The specification that follows is result of several days' intensive mulling. If some of its features seem odd, trust me; they will be justified later.
An anthology is a special file into which many small files can be collected with a resultant savings in disk and directory space. The anthology commands specified here make it possible to create anthologies, add and delete files in them, list their contents, and retrieve files from them.
You may collect many files in a single anthology. Usually, more files can be kept in an anthology than would fit on a disk as independent files. For example, a disk that can't hold more than 64 files could hold an anthology that contained more than 100 files, so long as their aggregate size fit the disk.
An anthologized file is not as accessible as one that stands by itself. The anthology as a whole can be copied, but the files within it are not visible to the rest of the system. They must be retrieved from the anthology, that is, copied out to become independent files, before anything else can be done with them.
You may compress text files with pack before putting them into an anthology; this will conserve even more disk space. If you do that, it will take two steps to gain access to an anthologized file: it must be extracted from the anthology; then it must be unpacked. The anthology as a whole must never be packed.
An anthology may be copied with pip, renamed with ren, erased with era, or have its file attributes set with stat (set in CP/M Plus). These are the only things that may safely done to an anthology; all other manipulations are done with one of the anthology commands.
An anthology must be created before anything else can be done with it. This is done with the antnew command,
antnew antname
The operand antname names the anthology to be created. It is a filespec, except that the filetype is not needed and will be ignored if given. The antnew program will not create an anthology that already exists. To recreate an anthology, you must first erase it.
The new anthology consists of more than one file. The primary file is antname.ant; it will contain the data of anthologized files. Two other files are part of the same anthology; their names are antname.anx and antname.any. They contain duplicate copies of the directory information for the anthology. The first, antname.anx, is the primary directory. The second is a spare copy for use in case the first becomes unreadable. Whenever an anthology is copied, all three files should be copied, as in
A>pip b:=a:antname.an?[v]
Files are added to, or replaced in, an anthology with the antput command,
antput antname filespec
The command first locates the anthology antname. Then it locates the file filespec on disk, reporting an error if it doesn't exist. The file is copied into the anthology. If a file with the same filename and filetype already exists in the anthology, this file replaces it.
The filespec may be ambiguous (contain asterisks or question marks). When it is, all files that match that filespec will be added to the anthology, one at a time. The name of each file is displayed as it is copied.
The copied files are not erased; they still remain on disk. If a file has a password (CP/M Plus only), the password will not be recorded in the anthology. When later the file is retrieved from the anthology, it will not be protected.
A copy of an anthologized file is retrieved for use with the antget command,
antget antname filespec
The command first locates the anthology antname. Then it searches the anthology for a file with the same filename and filetype as filespec. If none is found, an error is reported. Otherwise, that file's data is copied to disk as filespec, replacing any existing file of that name.
The filespec operand may be ambiguous. If it is, the command will retrieve every anthologized file with a matching name and type, one at a time.
You can display the names of files in an anthology with the antdir command,
antdir antname filespec
The command searches the anthology for all files whose filenames and filetypes match filespec. The filespec operand may be ambiguous. It may also be omitted, in which case it is taken to be *.*, that is, all files.
The command displays one line for each matching file. The line contains a number, which is the size of the file in 128-byte records, and the filespec. After displaying the matching files, antdir displays a total line showing the aggregate size of the listed files, the size of the anthology, and the amount of unused space in it.
You can delete members of an anthology with the antera command,
antera antname filespec
The command locates the anthology and searches it for a file whose filename and filetype match filespec; when found, the command deletes it. The anthology space occupied by the file remains allocated to the anthology (the anthology files will be no smaller on disk) but it is available for reuse by the antput command.
When filespec is ambiguous, antera behaves differently. It searches the anthology for matching files and, as it finds each one, displays its name on the screen and awaits keyboard input. If you want that file deleted, you must enter the letter y and press return. If the file is not to be deleted, type any other letter or merely press return; the file will be left alone.
Since an anthology may be entrusted with files that don't exist anywhere else, it's important to preserve the integrity of the anthology. Although the anthology commands are designed with this in mind, there are failures that they cannot guard against. For example, a disk read error (a "bad sector") in the CP/M disk directory makes all files on that disk inaccessible. A program can't be designed to protect against such a failure. Therefore you should always keep at least one, preferably two, backup copies of any anthology.
A disk read error that falls within a file makes some data irretrievable, but the rest of the file may be recoverable. However, the usual CP/M commands either read all of a file or none of it. The pip command, for instance, will not copy a file that contains a bad sector; it aborts when it sees the error and the data it copied preceding the error is lost.
The anthology commands, however, can usually recover at least part of a damaged anthology. The recovery method depends on whether the error affects the anthology data file antname.ant or the directory file antname.anx.
When an anthology command starts, it displays the note loading directory. When it has finished reading the directory, it displays the word done on the same line. If the program is aborted by a disk read error before the word done appears, you will know that the bad sector lies in the directory file antname.anx. To recover, rename the directory file to have a filetype of .bad (do not erase it; that would just free the bad sector for use by another file). Then rename the duplicate directory file, antname.any to have the filetype .anx, and try the command again. If the command works, immediately copy the .ant and .anx files to another disk. (Copy the .anx file twice, the second time giving the copy the type .any, so as to complete the set.)
The integrity of the anthology directory file is crucial; if it were to be corrupted so that it was no longer a true description of the anthology, all of the anthologized files could be lost or scrambled. All of the commands take great care to prevent this. They are designed so that, with the exception of a few critical seconds at the end of a command, program termination for any cause whatever -- a CP/M error message, a power failure, a disk error or a full disk -- will not harm the anthology on disk.
Furthermore, antera and antput, the commands that update the directory, apply several consistency checks to the directory before writing it to disk. If a test fails, the commands terminate with the message Integrity check, leaving the original anthology unharmed.
Because of this design, the general method for recovering from any anthology error is to analyze the cause of the crash and then to run the failed command again.
While antput is reading a file and copying it to the anthology, it displays the file's name and type. If it crashes due a disk read error (a "bad sector") at this time, the problem lies in the file being copied, not in the anthology. If antput crashes because the disk is full, it simply means that the anthology has gotten too big for the disk. In either case the anthology should be unharmed, although the files added by antput will not be found in it.
After copying the file, antput displays the word checking and reads back the copied data. A disk error at this point indicates a bad sector in the anthology data file, a sector that was used by the file just added. Files that were already in the anthology should be unharmed; use antget to retrieve them to another disk.
Antget displays the name and type of each file as it is retrieved, and done after closing the retrieved file. If antget is terminated for a full disk during this time, it simply means that there wasn't room for the retrieved file; the anthology is all right.
A disk read error during retrieval indicates a bad sector in the anthology data file. Note the name of the file that caused the error. That anthology member, at least, is ruined; further attempts to copy it will also abort. Use antdir to list all the files. Then use antget to retrieve all files except the one with the bad sector. Other files may also cause antget to crash, but most of them should prove recoverable. Retrieve all the files you can to other disks; discard the bad disk; and rebuild the anthology.
CP/M Plus supports file passwords. The password system interacts with anthology use in several ways.
When you use antput to add a password-protected file to an anthology, you may give the file's password as part of the filespec operand, or as the system default password, or you may wait for the program to prompt you for it. In any case, the file's password is not recorded in the anthology. The only security protection for anthologized files is the password of the anthology as a whole.
When you retrieve a file with antget, you may include a password as part of the filespec operand. The password will be applied at the read level to the file or files that are retrieved.
An anthology may be protected with a password. Remember that while an anthology consists of three files, the commands receive only one operand to name them. Therefore all the files of an anthology must have the same password. If they had different passwords, the commands wouldn't be able to work.
If you include a password in the antnew command as part of antname, it will be applied to all files at the read level. Alternatively, you may later apply a password to the data file, the directory files, or both, at any of the three levels read, write, or delete. The number and level of the passwords interacts with the commands' actions in a complex way.
These varied effects allow you to control the actions of a person who doesn't know the password:
Keep in mind that if you copy a protected file with pip, giving the password in the pip command, pip applies the password at the read level to its output.
My goodness, what a complicated specification. Fortunately, most of the material on passwords simply recapitulates functions that are already built into the toolkit. And we may safely defer all considerations of ambiguous filespecs until later. If we design a good mechanism to add, retrieve, or erase one file at a time, it won't be hard to extend it several files.
We had better start by defining a list of design objectives, or policies. We didn't do that for earlier programs. Because they were simple, they could be designed under the implicit policy of finding the cleanest, fastest algorithm that would accomplish the specified task. In the present task we will have many choices to make, and explicit objectives will help make them.
I believe that data integrity ought to be an anthology system's first objective. An anthology should be, so far as we can make it, proof against loss of data. Its design should be such that, if damage occurs, whether by hardware error or program failure, the loss will be confined to the smallest possible area. Such a design is said to be "robust."
It takes a lot of care to create a robust design. For example, we must keep in mind that our programs can be aborted by a disk error on any input, and design them so that no such failure will cause them to leave the anthology in an unusable state. We should also bear in mind that a program can be cancelled at any instant by a power failure or a press of a reset button. So far as possible, no failure of any kind should be allowed to damage the anthology.
Next come some design constraints. The design should not be based on recopying the anthology. The lib library manager, for example, makes a new copy of an object library every time it is run. Recopying is a simple management algorithm, but it has two major problems. First, processing time varies with the size of the anthology, not with the size of the files being added or retrieved. Second, it limits the size of an anthology to one-half the size of a disk. Our anthologies should be able to expand to nearly the capacity of one disk, and that implies that we will have to update or extend them in place.
Another constraint is that the design should reclaim data space automatically. In some library systems, the space occupied by a deleted member is simply lost; the user is expected to rebuild the anthology with a special "pack" or "reclaim" program when the waste space becomes excessive. I don't think that's tolerable. Space allocation can be easy for the program; it is always a major irritation for the user.
A final policy decision is, how large may an anthology become? The choice will affect the design at many points; a design that works well for half a megabyte may not be suitable for handling ten megabytes. There are only a few common disk sizes: there are many around 128 kilobytes (Kb); some around 500 Kb; some around 1000 Kb; and hard disks range upward from five Megabytes (5 Mb or 5,000 Kb).
The smallest disks need anthologies the most. Disks of 500 Kb and up usually have directories with 128 entries or more. Furthermore, an anthology larger than 500 Kb must contain either very large files (which don't benefit from anthologizing) or so many small files that keeping track of them would be very difficult. I think it is safe to set the maximum size of an anthology at 512 Kb; if the disk has greater capacity, the user has the option of creating more anthologies.
These objectives have several implications for our design. The first I saw was that control information -- the directory that shows which data belongs to what file -- must be stored redundantly, and apart from the data. The redundancy is necessary for robustness. While damage to file data affects at most one file, damage to the control information can affect several files or all files. If the directory is stored in duplicate in different places on the disk, the chances of recovering from a disk error are much better.
Some library systems maintain only a single file, interspersing control information among the data of the stored members. We can't do that. If we did, we'd have to read the anthology sequentially to find the control information. Then a disk read error would make inaccessible all the control information following the bad sector, violating our first design objective. Besides, if we are to store the directory data twice, at least one copy will have to be a separate file; so both copies may as well be separate files.
The objectives of not recopying and automatic reclamation will mandate the use of direct access disk I/O. In earlier programs we always processed files sequentially from start to end. The anthology commands will have to read and write "at random" (as it is commonly said); that is, they will need to skip around in the file, reading or writing at any point in any order.
The CP/M file system permits direct access to disk files, and the toolkit contains functions that support it. The fseek function can be used to position a file at any byte, so that subsequent input or output will begin at that byte. The frecseek function has the same effect, but it positions the file in terms of records of some fixed size, not in terms of bytes. The frewind function returns a file to its first byte so that it can be overwritten in place. We don't want to consider the details just yet, but we must know that these abilities exist.
We can design the data file knowing only that much, and knowing that CP/M files consist of a sequence of 128-byte records. We will treat the data file as an expanse of these records. Some of them will be occupied by the data of anthology members, while others may be free. When we add a file to the anthology, we will copy it into such free records are available, and append new records to the end of the data file as required.
The directory file will contain the control information that lets us know what members the anthology contains, what data records are free, and which records of the data file are owned by which members. When we retrieve a member, we will use the directory information to retrieve the data records in their correct sequence, writing each to an ordinary sequential file. When we erase a member, we will mark each of the data records it occupied as being available for use.
We must now choose a design for that directory information. There is any number of ways to structure the directory; however, we want a design that will be as simple and as robust as possible. A simple structure will make the programs easier to write and less prone to bugs. For a robust system, we must have a directory organization such that, if a few bits or bytes should be corrupted, only a limited amount of file data will be lost. Some directory structures, as we will see, collapse catastrophically if they are corrupted in the smallest way.
This is a real fear, by the way. There are three causes of data corruption. One is disk failure. In four years of personal computing, I've seen at least two errors that could only have been due to the disk controller mis-writing a byte and not detecting its own error. A second is a failure in RAM. Eight-bit machines lack the parity-check circuits that would detect failures in the RAM chips, and there is a small but genuine chance that what a program writes into RAM is not what it will read back. The most likely cause of data corruption, of course, is program error.
The anthology directory has nearly the same purpose as an operating system's directory of files on disk. Let's compare two of these to see what we can learn.
CP/M divides a disk into blocks of one or two kilobytes each and numbers the blocks. Each directory entry contains a filespec, an extent number, and a list of eight or sixteen block numbers. To process a file sequentially, CP/M finds the directory entry with the lowest extent number and reads out the block numbers from it. After processing those blocks, it looks for the directory entry with the next higher extent number. In order to know which blocks are in use, CP/M keeps a bit-map for each disk drive. When it first accesses a drive, it reads all directory entries. For each block number it finds, it sets the corresponding map bit to 1; afterward, free blocks are represented by 0-bits.
The CP/M scheme is fairly robust. If one directory entry is corrupted, it will affect only one or a few blocks of one file and at most one other file (two files might end up owning the same block number). However, the CP/M directory contains a lot of redundant information for large files (the repeated filespecs in each extent's entry), and it wastes space on small ones (the unused block-number positions in their entries).
The MS-DOS operating system is quite different. It, too, divides a disk into blocks (of 512 bytes) and numbers the blocks. However, it records the state of all blocks in a single table called the File Allocation Table (FAT). The FAT is an array of binary numbers, each corresponding to one disk block; that is, the nth entry of the FAT is related to disk block n. If a FAT entry contains zero, the related block is free.
An MS-DOS file is given just one directory entry, no matter its size. In the entry there is a single block number, that of the first block owned by the file. The rest of the file's space is recorded as a chain of numbers running through the FAT. To read a file sequentially, MS-DOS proceeds this way:
x := first block number
while (x <> -1)
process block x
x := FAT[x]
end while
Thus the FAT entry corresponding to each block of a file contains the number of the next block of the file.
The MS-DOS scheme avoids the redundancy of CP/M's directory, but it also avoids CP/M's robustness. Its fragility stems from the fact that the numbers in the FAT have no independent meaning. Every number's interpretation depends on the numbers that precede it in its chain. If a single bit in the FAT is altered, the corruption affects all of the blocks that used to follow that entry in the chain. Some blocks will be lost; others will end up being owned by two files. The block numbers in a CP/M directory don't have this interdependence; if one is corrupted, the others are still correct and usable.
MS-DOS compounds the danger by keeping the FAT in storage continually, refreshing it from disk as little as possible but writing it back to disk often. This ensures that when the in-storage copy is corrupted -- and it does happen, as many owners of IBM PCs will attest -- the error will quickly be made permanent.
In our directory, we will emulate the economy of MS-DOS by keeping a single array of record numbers. We will not, however, track a file's records by threading a chain through the table. Each entry in the table must stand alone, so that one corrupt entry won't cause other entries to be misinterpreted.
We can learn something else from the unfortunate example of MS-DOS: we must try to avoid committing a mistake to disk. It's bad enough to corrupt the directory in storage, but so long as we don't make the error permanent by writing it to disk its effects will be limited to a single program's run.
We can achieve this with two rules. First, we must never modify the data file in such a way that the old directory on disk is made invalid. If we stick to that, unexpected program terminations will cause no loss of data. The old directory will be a valid description of the data even after a crash, although it may not describe the changes the program attempted to create. This rule alone makes our system proof against crashes of all sorts -- disk read errors, disk full errors, the user pressing the reset button -- and furthermore we will be free to end the programs with the abort macro at any time.
The second rule is that, after modifying the directory in storage, we must try to verify its correctness before writing it back to disk. It won't be possible to detect all errors, but we can detect most of them. If we can catch a corrupt directory and abort, the first rule will ensure that we've done no permanent harm.
Of course, I wouldn't be saying all these things if I hadn't already prepared a design that met the objectives. We will examine it first at a very high level, deferring all questions of assembly-language implementation.
There will be a data file which we will view as an expanse of 128-byte records. We will number these records from 1 to MaxR, where MaxR is at least 4096 (4096 times 128 makes 512 Kb). There will be an array of integers called R indexed by record numbers, so that the value of R[r] tells the state of record r.
At this high level, we may pretend that there are two I/O functions, copyin(r) and copyout(r). The first will represent reading 128 bytes from the current input file and writing it to record r of the data file; copyout(r) will stand for getting record r from the data file and writing it to the current output file. These pseudo-functions translate directly into toolkit function calls.
We will also suppose that the directory can account for as many as MaxF files, MaxF being some useful number like 500 -- how many doesn't matter. We will number the files from 1 to MaxF and keep track of them in two arrays. The first, F, is an array of strings, the names of the files in filename.typ form. (If we were working in BASIC, we'd write DIM F$(MAXF).) The second, S, is an array of integers, the sizes of the files. Thus F[f] is the name of file f, while S[f] is its size as a count of 128-byte records. If there is no file f, F[f] will be a null string.
When R[r] is zero, record r is free. Otherwise the value of R[r] will be the number f of the file that owns record r. We will always allocate data records starting with the lowest number available. As a result, we could copy a file out of the anthology with this simple loop.
for r := 1 to MaxR
if (R[r] = f) then copyout(r)
end for.
That's the basic structure. It is certainly simple. We can make it quite robust with consistency checks. We can verify, for instance, that every entry of R contains either zero or a file number between 1 and MaxF, and that a file of that number exists (F[R[r]] is not a null string). We can also verify that every existing file owns exactly as many records as the array S says it should. If we add two two more variables -- AlFiles as a count of active files and Frecs as a count of free records -- we can make a complete set of tests. We can verify that there are exactly Frecs entries of zero in R and exactly AlFiles filename strings in F.
If we make these checks just before writing the directory back to disk, we will come close to ensuring that we never create an inconsistent directory. It is remotely possible that a directory could be corrupted in such a way that it would be inaccurate yet self-consistent, but the chances of that are so small that we can ignore them.
Let's design some support functions to make the use of these data structures simpler. (This isn't just a pedagogical dodge; I really did begin my design efforts by working out this set of support functions.) We can begin with functions to manage the filename arrays. The find function (Figure 8-2) searches for a filename string in the array F, returning its file number if it appears and zero otherwise.
find(str: string) returns file number
j := 1
repeat
if (F[j] = str) then return j
j := j+1
until (j = MaxF)
return 0
end find.
Figure 8-2. Support function find.
The add function (Figure 8-3) is used to add a new filename to the directory. It looks for an unused entry in F and sets it to the new filename string. If none exist, the directory must be full.
add(str: string) returns file number
j := 1
repeat
if (F[j] is null) then
F[j] := str
S[j] := 0
AlFiles := AlFiles+1
return j
endif
j := j+1
until (j > MaxF)
abort("directory full")
end add.
Figure 8-3. The support function add.
When retrieving a file we have to read out its records in order. We don't know where the records of a given file fall within the array R, but we have decided that they will appear in ascending order; that is, the next record has a higher number r than the current one. The nextdat function (Figure 8-4) finds the next record of a file, given the file number and the number of the current record. It expects that it won't be called unless there is a next record; hence it aborts with an integrity failure if it can't find one. Note the use of a while loop in Figure 8-4. Why do you suppose nextdat doesn't use a repeat loop as the prior functions did?
nextdat(f:file number, last: record number) returns record number
j := last+1
while (j < MaxR)
if (R[j] = f) then return j
j := j+1
end while
abort("integrity failure!")
end nextdat.
Figure 8-4. The support function nextdat.
With nextdat we can write pseudo-code for the heart of antget, a loop that copies a file f from the anthology.
r := 0
s := S[f]
while (s > 0)
r := nextdat(f,r)
copyout(r)
s := s-1
end while
Again we have used a while loop. Here it is because we will allow an anthology member to have a size of zero. If the user wants to anthologize an empty file, we will allow it.
When we copy in a new or replacement file we will have to allocate record numbers to hold its data. One way to do this is to scan the array R from beginning to end, looking for an entry that contains zero. But more often than not, when we want one free record we will shortly want another. If we scan from the beginning each time, the program will waste progressively more time on fruitless comparisons on each call. Let's borrow a trick from nextdat and search for the next free record after the last one found. This is shown in nextfree in Figure 8-5.
nextfree(f: file number, last: record number) returns record number
j := last+1
while (j <= MaxR) do
if (R[j] = 0) then
R[j] := f
Frecs := Frecs-1
return j
endif
j := j+1
end while
abort("dataspace full")
end nextfree.
Figure 8-5. The support function nextfree.
With nextfree we can sketch the heart of the antput command.
r := 0
s := 0
while (not end of input file)
r := nextfree(f,r)
copyin(r)
s := s+1
end while
S[f] := s
This simple formulation ignores the problem of replacing a file that already exists in the anthology.
When erasing files we have to free record numbers. We may as well have a support function for this purpose. The free function (Figure 8-6) releases all the records owned by a file, performing a consistency check as it goes.
free(f : file number)
size := S[f]
j := 1
repeat
if (R[j] = f) then
R[j] := 0 !
Frecs := Frecs+1
size := size-1
endif
j := j+1
until (j > MaxR)
if (size <> 0) then abort("integrity failure")
S[f] := 0
end free.
Figure 8-6. The support function free, used to erase a file.
We could use free as it stands to erase a file, but not to replace one. Replacement will occur when antput is asked to copy in a file that already exists in the anthology. The simple way to do the replacement is to apply free to that file, releasing all its existing records, then to execute the simple loop shown above. But that loop calls nextfree, which might choose one of the records that was just freed. The new file data would overwrite some or all of the old version of the file.
For robustness we must not alter the data file in such a way as to make the old directory on disk invalid. But this replacement procedure would do just that. After freeing the old file's records and reusing some of them, the old directory wouldn't describe that file correctly. If the program crashed before it could rewrite the updated directory, the anthology would be ruined.
The only solution is to have two kinds of "free" records: records that are really free, and those that will be free when the directory is updated on disk. We might use a value of -1 in R to signify a record that is conditionally free. That will signify that it belongs to no file, but will prevent nextfree from selecting it for use. When we are ready to commit the directory to disk we will convert these marks to zeros, probably as part of performing the consistency checks.
Only antput and antera will use free; antput needs to free records conditionally while antera won't care. Therefore the statement R[r]:=0 in Figure 8-6 may as well be R[r]:=-1 for all cases.
Just as a check on the design, let's sketch the process of committing a directory to disk. This will be done at the end of the antput and antera commands. The logic is shown in Figure 8-7. It depends on an additional array of integers T, in which the true count of records owned by each file is accumulated.
commit
for j := 1 to MaxF do: T[j] := 0
nfree := 0
for j := 1 to MaxR do
if (R[j] = -1) then R[j] := 0
if (R[j] = 0) then
nfree := nfree+1
else
f := R[j]
if (f is not in 1..MaxF)
abort("integrity failure!")
if (F[f] is null)
abort("integrity failure!")
T[f] := T[f]+1
endif
end for
if (nfree <> Frecs)
abort("integrity failure!")
nfiles := 0
for j := 1 to MaxF do
if (F[j] is not null) then
if (T[j] <> S[j])
abort("integrity failure!")
nfiles := nfiles+1
endif
end for
if (nfiles <> AlFiles) then abort("integrity failure!")
end commit
Figure 8-7. Checking the consistency of the directory before writing it to disk.
This scheme seems to work smoothly enough, at least as pseudo-code. But how can it be implemented in assembly language? A literal translation of the pseudo-code design would use the arrays R, F and S, each of a fixed size both on disk and in storage. There are disadvantages to this. Arrays are tedious to manipulate in assembly language; for example, it would be quicker if, where the pseudo-code indexes the array of filenames as F[f], we could actually use the address of that filename string without going the process of computing it from the index and the base address of the array.
A more important problem is that arrays of fixed size are both bulky and inflexible. Consider the storage requirements. We must allow at least 4,096 record numbers, each a two-byte integer. That's 8,192 bytes. We should allow at least 100 filenames. It takes 13 bytes to record the string "filename.typ," plus two bytes for the size of the file -- say 16 bytes for each file. That's another 1,600 bytes. A directory composed of fixed-size, preinitialized arrays would take nearly 10 Kb of disk space regardless of the amount of data in the anthology it describes. Furthermore, its fixed size would hamper the user who needs to store exactly 101 files, or the one who needs to store just 4,097 data records.
The directory may take up a fixed amount of space in storage, but its size on disk should be proportional to the amount of data in the anthology. This is especially important for small disks. Furthermore, it should allow a flexible upper limit on both the number of files and the amount of data. The array R should be able to expand at the expense of arrays F and S, and vice versa.
This can be done in assembly language. The first step is to merge the arrays F and S. The merged array -- let's call it FS -- is an array of data structures, each entry containing a filename string, a size integer, and a spare byte to make up 16 bytes (Figure 8-8).
Figure 8-8. The layout of array FS in storage.
Now imagine a block of storage set aside to hold the arrays. The array R, the list of integers that describes the data records, may occupy the low-address end of the area. The array of file structures can be packed into the high-address end of the area. The space between the end of R and the start of FS is available for the expansion of either.
This situation is diagrammed in Figure 8-9. It shows a block of storage that starts at some address Base. Base is also the address of the first entry of array R; the address TopR points to the first unused word following R.
Figure 8-9. Arrays R and FS can share a block of storage.
Some distance beyond the end of R, the array FS begins. Address BotF points to the first entry of FS. The address TopF points to the byte after the end of FS -- the end of the block of storage.
Under this arrangement, both arrays are open-ended. If we need space to record a new file, we get it by subtracting 16 from BotF. If a new data record must be allocated and none of the existing ones are free, we can create space in R by adding 2 to TopR. In either case, the directory becomes full only if TopR becomes greater than BotF.
Under this scheme the contents of the file entries are no longer indexes to an array F; instead they are addresses of FS records.
Notice that these changes in the representation of the data are not fundamental. They don't affect the logic that we laid out in the last section; the pseudo-code functions displayed there are still accurate statements. There is still an array R; its entries still reflect the state of data records; the contents of arrays F and S still exist and have their planned meanings.
When the directory arrays are laid out in storage this way, it becomes easy to read and write only the relevant parts to disk. The relevant part of the array R extends from Base to TopR; that of FS from BotF to TopF. Define
SizeR := TopR - Base
SizeF := TopF - BotF
Now suppose that when a directory is written to disk, we write only this information:
In a new anthology, SizeR and SizeF are both zero, so the directory of a new anthology would consist of nothing but two words of zero. Once files have been put into an anthology, its directory files on disk will be larger, but their size will always be proportional to the amount of data in the anthology.
Reading a directory into storage is the reverse process. We would allocate an area of storage and set its start and end addresses in Base and TopF. Then we would read a word to SizeR and a word to SizeF. If the sum of those words was greater than the size of our area, we'd have found an integrity failure; otherwise we would
A directory written to disk by one program will be read to storage in other programs. There is no guarantee that the value of Base will be the same in different programs, yet the record entries in R are supposed to be addresses. These addresses won't be accurate if they are loaded to a different Base point.
One more change will fix this problem. After the directory is loaded, R[r] will be the address of the FS entry that owns that record. However, just before writing the directory to disk we will go through R and modify each nonzero entry this way:
R[r] := TopF-R[r]
That converts what was an address into an offset, the distance in bytes from that file entry to the top of the storage area.
After reading a directory we will perform the reverse conversion,
R[r] := TopF-R[r]
(yes, the same expression), to convert an offset back into an absolute address. This peculiar conversion handles two kinds of difference between one program and another. The second program might read the directory to a different area of storage, and it might use a larger or smaller block of storage to hold the directory. The only thing we will depend upon is that all programs will place the array FS at the top of the storage block.
There are two ways in which we could update the directory files. One way is to open them with frewrite and write them sequentially. That replaces the old files with new ones; while the writing is going on both old and new files exist.
The other way is to use the direct-access functions to write the directory data back into the existing files. That could be done by applying frewind to seek back to the head of the files and then writing them sequentially. The new data would be written over the old data in the same disk sectors.
There are reasons for preferring the second method. The first is integrity. Once the directory data have successfully been read from a certain part of the disk, that expanse of magnetic oxide is known to be readable, and we ought to stick with it. If we make new files, they'll go to a new, unproven part of the disk's surface. Another reason has to do with passwords under CP/M Plus. The directory file may have a password at the write or delete level. When the toolkit functions make a new or replacement file, they must install a password at the more stringent read level, so rewriting the directory would have the effect of forcing the password level up. On the other hand, the directory might have only a delete-level password, which normally needn't be given when a file is updated in place. But when a file is rewritten, the old file must be erased, so the password would be required (or would cause a program abort if it wasn't given).
Yet another reason is that while a file is being replaced, two copies exist. On a nearly-full disk, that might force a needless disk-full error. So all in all, it seems best to use direct-access functions to rewrite the directory in place.
Ah, but that last point brings up a new worry. Imagine this scenario: Several files are added to an anthology on a nearly-full disk. The files' data fits, but the new filenames and record numbers cause the directory to expand across a kilobyte boundary. CP/M must allocate a new disk block to each directory file -- and runs out of disk space. If the files are updated from first record to last, the program will be aborted before it finishes writing the directory, but after it has updated that part of the directory that fit in the old directory's allocation. The directories (or perhaps only one of them) are no longer a true description of the anthology. This violates one of our integrity rules.
It took hard thought to arrive at the obvious solution to this danger: use the direct-access services to update the directories from last record to first. The only thing that will make CP/M abort a program during file output is running out of disk space, but that will happen only when the program is writing to a new sector of the file. But if we are writing to a new sector, we can't be overwriting old directory data. Therefore we must write those records of the directory that might be new sectors first. If that causes CP/M to abort us, we can't have overwritten the old directory data; it will still be there, a true description of the anthology.
The routine that updates the directory files on disk will have to write them backwards, from their last 128-byte record back to their first. In order to do so conveniently, the values of SizeR and SizeF will have to be stated in units of 128 bytes; that is, they will have to be counts of 128-byte records.
Wait, there is one more hazard. The directory files antname.anx and antname.any are supposed to be identical. But what if the .anx file is written correctly and then we encounter disk-full while writing .any. The files will be out of step; one will be updated while the other is back-level.
The solution is to write the files alternately by record, from last record to first. Write the last record of .anx; write the last record of .any; write the next-to-last record to .anx and then to .any. This will force CP/M to allocate the new disk blocks that are needed by both files before we write over the old data of either file. If disk-full occurs, neither file's original contents will be changed; if not, both will be changed.
Whew! This is where an integrity policy can lead one. Is it worthwhile? Well, for this application, perhaps not. But the software market is being flooded with so-called "database managers" to which people entrust data of immense value. How many of them take even this amount of care for data integrity and security? Most ignore all such considerations, simply dumping the whole responsibility onto the user with many platitudes about "make lots of backup copies." You have seen the issues and at least a few of the solutions. Should you set out to build a small database manager using the direct-access services of this book's toolkit, you'll have some idea of what to do.
In the next chapter we will attempt to implement this monster we've conceived
We have set the essential parts of the design for the anthology system: the data structures of the directory, the fundamental routines to manipulate them, and the method of storing directory and data on disk. Implementation may commence, and the first thing to implement is a collection of supporting modules.
In the vocabulary of the CP/M assemblers, a "module" is the unit of object code that rmac produces, lib stores, and link merges with other modules to make a program. That's a trivial use of a good word. It's no accident when we isolate a section of code as a module; we do it in order to isolate a functional abstraction.
The supporting functions displayed in the last chapter are functional abstractions. To invoke free(f) is to say "free the records owned by file f; I don't care how, just do it." We can be that carefree while designing the program, and again while coding it, but at some point we must turn our attention to the "how" of accomplishing free(f). Since its "how" is of no interest to the clients of free, there's no need to embed its code in another program's file. We'll only write and assemble it once (we hope), so it would be a waste of machine time to put it where it would be reassembled when some other code was. It will be needed in some programs and not in others, so it would be a waste of space to embed it in an object unit that was linked by programs that didn't need its services. That's why we will make modules (in the rmac sense) of our modules (functional abstractions).
Although they are independent of each other, the modules will require access to information that is common to all of them, notably the anthology files, the arrays R and FS, and the numbers that define the boundaries of those arrays.
If these global variables are defined in one module (assembly source file) they won't be available from others. One solution would be to create a module composed of nothing but data declarations, giving each object -- Base, TopR, etc. -- a public label. A module that needed access to a variable would name it as an external label.
In retrospect that would have been the best solution, but I chose another: the common block. A common block is a named segment of storage. Each module that needs access to its contents includes a definition of the common block; the link program makes sure that each module's definition references the same area of storage. The common block for the anthology system is shown in Figure 9-1.
common /ANTCOM/
; This common area definition is included in all, or most
; anthology programs. It defines the directory in storage.
; The filedefs are assembled in module ANTSUP.
AntHdr equ $ ; following words precede directory
SizeR ds 2 ; size of array R
SpanR ds 2 ; # of 128-byte records over R
SizeF ds 2 ; size of array FS
SpanF ds 2 ; # of 128-byte records over FS
AlFiles ds 2 ; count of allocated files
Frecs ds 2 ; count of free records
AntHdrLen equ $-AntHdr ; bytes written at head of file
Base ds 2 ; -+base of dir. (array R)
TopR ds 2 ; -+low free byte
BotF ds 2 ; -+bottom byte of FS
TopF ds 2 ; -+byte after directory
SizeDir equ 16384 ; in-core size of directory
FSsize equ 14 ; offset to size in FS entry
FSlen equ 16 ; size of an FS entry
AntDat ds FcrLen ; data file antname.ANT
AntDirX ds FcrLen ; directory copy antname.ANX
AntDirY ds FcrLen ; directory copy antname.ANY
AntSeq ds FcrLen ; sequential file in/out
cseg ; end of common
Figure 9-1. The definition of the common block.
This section of code has to be included in every module of the system. Therein lies one danger of common blocks. The link program guarantees that every definition of the block will refer to the same area of storage, but it cannot check on the use of the storage. There will be no error indication if one module's definition of the contents of the common block differs from another module's definition. If one module stored, say, TopR in the eighth word of the common area while another module retrieved it from the sixth word, a very tricky bug would result. The modules would look correct in isolation but wouldn't work together.
The common block's definition must be identical in every module or catastrophe will follow. I made that happen by writing Figure 9-1 as a separate file and pulling it into each file as it was edited. But sure enough, around the fourth module I discovered that another word was needed in the common definition. Then I had to go back and correct the previous modules.
Assume that the code of Figure 9-1 is part of all the remaining figures in this chapter.
Let's look at the code of the most useful modules. In truth, I wrote the modules needed by antnew first, then wrote and tested antnew, then the modules needed by antput, then antput itself, and proceeded in that fashion until all the programs were done. But I had the structure of the whole system in my head in more detail than could be shown in the last chapter. That gave me confidence that it would all come together. You may not have the same sense of the design's solidity, and seeing the modules first may reassure you.
Glance back to figure 8-2, the pseudo-code of a function that finds a name in the directory. Its implementation appears in Figure 9-2.
name 'ANTFIN'
;===============================================================
; find(DE-+FS entry or HL=zero
public ANTFIN
ANTFIN:
push psw
push b
lhld TopF
FINloop:
lxi b,FSlen
ora a ; clear carry
dsbc b ; HL-+next lower FS entry
lbcd BotF ; is HL now lower than lowest?
dsbc b ; (carry is still clear)
jrc FINfail ; (yes, we tried them all)
dad b ; no, restore HL
strcmp +H ; is this the wanted entry?
jrz FINhit ; (yes, stop)
jmp FINloop
FINfail: lxi h,0 ; return HL=0 for failure
FINhit: ; return HL-+FS entry for success
pop b
pop psw
ret
Figure 9-2. The code of ANTFIN, the find function.
Study Figure 9-2 carefully, for it illustrates a method of scanning an array that is used throughout these routines. The array of filename-and-size entries begins at the top (the high-address end) of the in-storage copy of the directory. Its oldest, highest entry ends in the last byte of that area, and common word TopF contains the address of the following byte. Its latest entry is at the lowest address; that address is in common word BotF. In a new anthology the array is empty and BotF = TopF.
The array can be scanned in either direction. In Figure 9-2 it is scanned from highest to lowest address. Before the loop, register HL is set from TopF. At the top of the loop, the dsbc instruction is used to back HL up by the length of one entry. That instruction has to be used with care; it subtracts from HL the contents of a register pair plus the value of the carry flag. That is useful in certain arithmetic routines, but most often the carry flag is irrelevant and we must be sure that it is zero before doing the subtract. The easiest way to clear it is the instruction ora a. That changes no registers, but like all logical instructions it sets carry to zero.
After the subtract, HL points to the next lower array entry -- or does it? If its contents have become numerically less than BotF, it points only to unknown, useless space. To find out if this is the case, HL must be compared to BotF. There is no word-compare in the Z80 instruction set, but binary comparison is nothing more than binary subtraction. In Figure 9-2, BotF is subtracted from HL. If that sets the carry flag, BotF was greater -- there was a "borrow" out of the high bit of HL -- and the loop must end in failure. If not, HL points to a valid array entry. Well, it used to; it no longer does because BotF has been subtracted from it. Its contents must be restored by adding BotF back into it. Then the input string may be compared to the filename string in the array.
When find fails, the new filename may be added to the directory with function add. Its pseudo-code is shown in figure 8-3; its implementation appears in Figure 9-3.
name 'ANTADD'
;===============================================================
; add(DE-+new FS entry initialized with
; the filename string -- or, if there are no free entries,
; aborts with "Directory space is full."
dseg
NoDir: db 'Directory space is full.$'
cseg
public ANTADD
ANTADD:
push psw
push b
lhld TopF
ADDloop:
lxi b,FSlen
ora a
dsbc b ; HL-+next lower FS entry
push h ; (save that)
lbcd BotF
dsbc b ; off the bottom now?
pop h ; (recover -+next entry)
jrc ADDnew ; (yes, allocate a new name)
mov a,m ! ora a ; no, does HL-+free entry (null)?
jrz ADDold ; (yes, reuse old name)
jmp ADDloop ; no, try another
; All allocated FS entries are in use. HL=BotF-16, which will
; do for a new entry provided it doesn't overlap TopR.
ADDnew:
lbcd TopR
push h
ora a
dsbc b ; compare HL vs. TopR
pop h
abort c,NoDir ; ..and quit if they overlap.
shld BotF ; set the new BotF, extending array
mvi m,0 ; make new entry null
; HL-+an available FS entry (contains a null string). Copy
; DE-+string into it...
ADDold:
xchg ; append wants DE-+source
strappnd +H
xchg ; recover HL-+array entry
; ...and set its size word to zero
push h
lxi b,FSsize
dad b ; HL-+size field
xra a
mov m,a ! inx h ; zero it
mov m,a
pop h
; Whatever we did, there is now one more active file entry.
lbcd AlFiles
inx b
sbcd AlFiles
pop b
pop psw
ret
Figure 9-3. The code of ANTADD, the function add.
The pseudo-code plan for add was based on the implicit assumption that the array of filenames had a fixed size. It showed a simple scan for the first unused entry. The real situation is more complicated since the array has a flexible size. It may contain an unused entry (if a file had been erased), but if it does not, it can be extended downward to create a new, unused, entry.
The code in Figure 9-3 starts with a scan down the file array much like that in Figure 9-2. The loop has, however, two exits. If it locates an empty entry (one containing a null string), the new filename may be inserted there. If it reaches the end of the array, however, it attempts to create a new entry. When this succeeds, the case of "add a new entry" has been made identical to the case of "reuse an old entry"; that is, the outcome of "add a new entry" is identical to that of "find an unused entry" -- both end up with HL addressing an available entry. This trick of reducing one case to another is a useful one.
The dsbc instruction is used for word comparisons twice in Figure 9-3, and both times the original value in HL is preserved by pushing and popping HL, whereas in Figure 9-2 it was done by adding back the compared value. The difference is that in Figure 9-2 the contents of HL mattered only in one case and were irrelevant in the other. In Figure 9-3, HL's original contents are needed on both legs of the branch. The branch is taken on the state of the carry flag, but if HL were restored with an add, the restoration would wipe out the state of carry that determines the branch. Accordingly HL must be restored with the slower stack operations.
The nextdat function, planned in figure 8-4, was specified to return the number of the next record owned by a file. It amounts to a simple scan upward through the array of record numbers, R, looking for the next entry that contains the index of the given file.
As implemented in Figure 9-4 the simplicity is rather swamped by the necessary switch in mental levels between indexing an array -- checking the value of R[r] -- and addressing a sequence of two-byte words in storage. The arithmetic involved in the indexing is discussed in the comments at the head of Figure 9-4. The fact that the Z80 cannot treat stored words as units complicates things further. Follow the progress of HL through the loop carefully. It is used to look at first one byte of a word, then the next; but when the loop ends HL is pointing, not at the start of a word, but at its middle.
name 'ANTND'
;===============================================================
; nextdat(HL-+FS entry, DE=last record number):
; Return the next record number after DE which is owned by
; HL-+file. The caller is expected to know whether there IS a
; next record or not, so if there is not, we abort with an
; integrity check 1.
;
; We receive and return DE as an integer record number "r" from
; 1 to (TopR-Base)/2. Internally we deal with addresses of
; record slots, Base+2*r.
;
; Record numbers are origin-1 while the array is really origin-0
; so the slot indexed by DE on entry is Base+2*(DE-1). But we
; want to start with the NEXT slot, so we begin at Base+2*DE.
dseg
msgIC1: db 'Integrity check #1.$'
cseg
public ANTND
ANTND:
push psw
push b
push h ; save FS pointer
lhld Base
dad d ; Base + 2*DE gives...
dad d ; ...HL-+next record slot
pop d ; DE=FS pointer to compare
lbcd TopR ; BC=limit address for checking
NDloop:
ora a
dsbc b ; has HL got up to TopR?
abort nc,msgIC1; (if so, somebody goofed)
dad b ; no, restore HL
mov a,m ! inx h
cmp e ; compare low bytes
mov a,m ; and set up for high ones
jnz NDmiss
cmp d ; compare high bytes
jz NDhit ; (entire word matched)
NDmiss: inx h
jmp NDloop
; HL-+FS entry.
; Convert HL into a record number as (HL-Base)/2 + 1.
NDhit:
dcx h ; back to 1st byte of word
lbcd Base
ora a
dsbc b ; (carry now clear, HL was += BC)
rarr h ; 0 + carry
rarr l ; carry + carry
inx h ; make origin-1
xchg ; DE=record #, HL-+FS entry
pop b
pop psw
ret
Figure 9-4. The code of ANTND, the function nextdat.
This routine can encounter an impossible situation. If nextdat is called, its caller must expect that a "next" data record exists. But one might not; what should nextdat do if it can't find one? Either the calling program has a bug or the directory has been corrupted; whichever, the integrity of the system is in doubt and the only safe course is to abort the program. The routine quits with the first of what eventually became nine "integrity checks." The message it issues isn't very informative, but then it is never supposed to be seen anyway. If the situation ever does arise, there isn't much more that can be said about it than "aghhhh!"
The function nextfree, whose plan appeared in figure 8-5 and whose code is shown in Figure 9-5, has some of the characteristics of both add and nextdat. Like nextdat, it scans upward through the array R. Like add, it is looking for an unused entry and, if its search fails, it tries to create a new entry by extending the array.
name 'ANTNF'
;===============================================================
; nextfree(HL-+FS entry, DE=record number):
; Return the record number of the next FREE record slot after
; DE, and allocate it to HL=file address. If no free (zero)
; entries are found before TopR, create one by sliding TopR
; up, aborting if that overlaps BotF.
; See notes on index arithmetic in ANTND.
dseg
NoDat: db 'Data space is full.$'
cseg
public ANTNF
ANTNF:
push psw
push b
push h ; save FS pointer
lhld Base
dad d
dad d ; HL-+next record slot
pop d ; DE=FS pointer to compare
lbcd TopR ; BC=limit of ever-used records
NFloop:
ora a
dsbc b ; has HL got up to TopR?
jnc NFnew ; (yes, make a new one)
dad b ; no, restore HL
mov a,m ! inx h
ora m ; is this slot zero?
jz NFold ; (yes, use it)
inx h ; no, step over it
jmp NFloop ; ..and continue
; BC=HL=TopR, and no zero entries were seen. If TopR+2 + BotF,
; increment TopR and use this slot. Otherwise abort.
NFnew:
inx b ! inx b ; new TopR, maybe
lhld BotF
dsbc b ; new TopR + BotF? (equal is ok)
abort c,NoDat ; (yes, array R is full)
sbcd TopR ; no, ok, store new TopR
mov h,b ! mov l,c ; and set up HL
dcx h ; ..to point to 2nd byte of slot
jmp NFboth
; We found a record entry that was previously used but is now
; free. We will use it, so decrement Frecs.
NFold:
lbcd Frecs
dcx b
sbcd Frecs
; HL-+2nd byte of a record slot that is available. Store DE
; into it to claim ownership. Then convert the address into
; a record number: 1+(HL-Base)/2
NFboth:
mov m,d
dcx h
mov m,e
lbcd Base
ora a
dsbc b
rarr h
rarr l
inx h
xchg ; DE=record #, HL-+FS entry
pop b
pop psw
ret
Figure 9-5. The code of ANTNF, the function nextfree.
Both the add and nextfree functions could be frustrated by finding no room to expand their arrays, and both abort when this happens. They display different messages, but in fact both are reporting the same condition -- that the two arrays have met in the middle of the in-storage directory. Fortunately, neither message is likely to be seen. The in-storage directory is 16 Kb long, allowing for a megabyte of data, a thousand filenames, or a generous mixture of the two.
The function free, whose plan was shown in figure 8-6, was specified as releasing the ownership of all records owned by a particular file. In order to ensure that antput would not reuse the freed records during a single run (and thus make the old directory invalid), free was to mark each record slot with a -1.
The implementation of free appears in Figure 9-6. It differs from the pseudo-code plan in that it doesn't count the records it releases in Frecs. Since they aren't really free yet, they shouldn't be counted as such. During the commit procedure (the validity checks prior to saving the directory) the marked records will be set to zero and counted.
name 'ANTFRE'
;===============================================================
; free(HL-+FS entry): Find all record slots now owned by this
; file and disown them by putting -1 in each. Check that the
; number found agree with the size field, aborting if it does
; not.
;
; The marked slots are not added to Frecs at this time. They
; will be made officially free (zero) during the next Commit.
dseg
msgIC2: db 'Integrity check #2.$'
cseg
public ANTFRE
ANTFRE:
push psw
push b
push d
push h
lbcd TopR ; BC holds TopR for end-check
xchg ; DE contains -+FS as comparand
lxi h,0 ; count of found records...
push h ; ..is kept on top of stack
lhld Base ; HL scans array R
FREloop:
ora a
dsbc b ; TopR = HL?
jnc FREdone ; (yes, done)
dad b ; no, restore HL
mov a,m ! inx h ; A = low byte, HL-+2nd byte
cmp e
jnz FREmiss ; (low-byte mismatch)
mov a,m
sub d ; (leave A=0 if equal)
jnz FREmiss ; (high-byte mismatch)
dcr a ; A = FFh
dcx h
mov m,a ; 1st byte = FF
inx h ; HL-+2nd byte again
mov m,a ; record slot = -1 (FFFF)
xthl
inx h ; increment count of freed slots
xthl
FREmiss: inx h ; HL-+next slot
jmp FREloop
; All in-use slots of R have been scanned. Those that were
; owned by the indicated file have been set to -1, and the count
; of such slots is on the stack. Make sure it's the same as the
; size field of the FS entry, and set that size to zero.
FREdone:
lxi h,FSsize
dad d ; HL-+ size field
pop d ; DE=count
mov a,e
cmp m
jrnz FREugh
mvi m,0 ; zero low byte
inx h
mov a,d
cmp m
jrnz FREugh
mvi m,0 ; ..and high byte
pop h
pop d
pop b
pop psw
ret
FREugh: abort ,msgIC2
Figure 9-6. The code of ANTFRE, the function free.
As often happens, the body of the loop needed one more variable than I had registers to hold. The Z80's architecture, however, makes the top word of the stack almost as accessible as a register pair. I elected to keep the count of freed records on top of the stack, switching it into HL when it needed incrementing.
The anthology design assumed the existence of two functions for I/O between the data file and a sequential file: copyin to read a file's record and store it in the anthology; copyout to copy an anthology record to a sequential file.
I said that these routines would translate to a series of toolkit macros, and indeed they do. The copyin function's code is shown in Figure 9-7. It requires only a record number (as it would be returned by nextfree), Figure 9-5) and the use of the files defined in the common area. Part of its simplicity stems from its assumption that the sequential file is not at its end; its caller is supposed to assure that.
name 'ANTINP'
;===============================================================
; copyin(DE=r): record r has been allocated to the current file
; and the AntSeq file is not at end of file. Read its next
; 128-byte record and write it to the anthology data file.
dseg
buffer: ds 128 ; buffer for data I/O
cseg
public ANTINP
ANTINP:
push psw
push b
push d
push h
push d ; save record number again
lxi d,AntSeq
lxi h,buffer
lxi b,128
fgetblok +B
lxi d,AntDat
pop h ; HL=record number
frecseek +H ; seek to data record
lxi h,buffer
lxi b,128
fputblok +B
pop h
pop d
pop b
pop psw
ret
Figure 9-7. The code of ANTINP, the function copyin.
The copyout code appears in Figure 9-8. It has to check for one impossible condition. The frecseek function that it uses to position the data file at the required record returns a signal in the Zero flag. If it returns Zero true, it is because CP/M reported that no data existed at that file position (similar to the way in which the sequential-input functions return Zero true at end of file). This should never occur; the record number given to copyout ought to have come from nextdat and, if it is owned by this file, there ought to be data in it. If it does occur, the directory or the copyout's caller has lost all credibility.
name 'ANTOUT'
;===============================================================
; copyout(DE=r): record r is the next in sequence for the
; current output file. Read it from the data file and write it
; to the output file. If the seek operation returns Z true,
; meaning "seek to unwritten data," abort -- we're confused.
dseg
buffer: ds 128 ; buffer for data I/O
msgIC3: db 'Integrity check #3.$'
cseg
public ANTOUT
ANTOUT:
push psw
push b
push d
push h
xchg ; HL=record number
lxi d,AntDat
frecseek +H
abort z,msgIC3
lxi h,buffer
lxi b,128
fgetblok +B
lxi d,AntSeq
lxi b,128
fputblok +B
pop h
pop d
pop b
pop psw
ret
Figure 9-8. The code of ANTOUT, the copyout function.
Each anthology command will need to fetch its first command operand and use it to set up the filedefs of the three anthology files. In addition, each command ought to follow our convention of displaying a usage message when called with no operands or an operand of only a question-mark. These essential formalities are perfect for a module their own. I put them in a module named ANTSET (Figure 9-9). Much of what this setup routine was patterned after the chkops module we used in earlier programs. Two of its features are new.
name 'ANTSET'
;===============================================================
; Setup(DE-+usage message):
; Get the first command operand and use it to set up all three
; anthology filedefs in the common area with drivecode, filename
; and password, and their appropriate filetypes.
;
; The count of operands is returned to the caller in reg. A.
; If there is no first operand, or if it starts with "?," abort
; with the given usage message. Other messages:
; FRUMMAGE is not a valid anthology filespec
; FRUMMAGE is ambiguous; anthology spec must be explicit
; Reopen the common definition and assemble into it the filedefs
; of the anthology files.
common /ANTCOM/
org AntDat ; assemble filedefs into common
filedef 2048
org AntDirX
filedef 1024
org AntDirY
filedef 1024
org AntSeq
filedef 1024
dseg
tokens ds 1 ; save token-count for exit
usage ds 2 ; save -+usage message
string strspace 80 ; space to build abort-message
Ant: strconst 'ANT'
Anx: strconst 'ANX'
Any: strconst 'ANY'
inval:
strconst ' is not a valid anthology filespec.$'
ambig:
strconst ' is ambiguous; anthology spec must be explicit.$'
cseg
public ANTSET
ANTSET:
push b
push d
push h
xchg
shld usage
savetail
sta tokens
ora a ; any tokens at all?
jnz SET1 ; (yes, continue)
abusage: ; come here if token 1 is "?"
lhld usage
xchg
abort ,+D
SET1: mvi a,1
tailtokn +A
ldax d
cpi '?' ; a plea for help?
jrz abusage ; (yes, abort w/ usage msg)
; The first token is present and is not a question mark. Test
; it by assigning it to one file.
xchg ; HL-+token 1
lxi d,AntDat
fassign +H ; assign D:filename.typ;pass
lxi d,inval
jz abspec ; (bad filespec)
lxi d,ambig
cpi '?'
jz abspec ; (ambiguous spec)
; The first-token filespec is a valid one at least for the
; general case. Assign it to the other two files as well.
lxi d,AntDirX
fassign +H
lxi d,AntDirY
fassign +H
; Now force all three to have their correct filetypes, over-
; riding any filetype the user may have given.
typeonly equ 00100b ; no pass, do type, no name, no drive
lxi d,AntDat
lxi h,Ant
fassign +H,,typeonly
lxi d,AntDirX
lxi h,Anx
fassign +H,,typeonly
lxi d,AntDirY
lxi h,Any
fassign +H,,typeonly
; That's that, the filedefs are ready to open.
lda Tokens
pop h
pop d
pop b
ret
; common routine to abort with a message composed of HL-+string
; followed by DE-+string.
abspec:
push d
lxi d,string
strcopy +H
pop h
strappnd +H
lxi d,string
abort ,+D
Figure 9-9. The code of ANTSET, the setup function.
The first new feature is the definition of the anthology files. Every module's source file contains the identical definition of the common block as shown in Figure 9-1. That definition reserves space for the three filedef macros which will represent the parts of the anthology, but it doesn't contain the macro calls themselves.
Here in the ANTSET source file the assembler is made to reopen the common block, move back to each of these names, and assemble the filedef macros. When constant values are to be assembled into a common area, it is best to do it this way. When link processes constant data in a common area, it simply overlays each module's constants on the prior one's. If two modules assemble constants in the same byte, the one that link happens to process second will prevail. It's best to make sure that only one module actually assembles anything in a common area.
When rmac is made to reopen a common area this way, however, it also produces an "E" error flag on one statement in the common definition. This appears to be a bug, but it seems to cause no harm.
The second new thing in Figure 9-9 is the use of a partial assignment of a name to a file. The fassign function is familiar; it assigns a filespec, contained in a character string, to a file definition. Normally it assigns all the parts of a filespec: drivecode, filename, filetype, and password (the password is accepted, but has no effect, under CP/M 2.2). However, fassign may also be used to assign only one of those parts, or any combination of parts, while leaving the others unchanged.
The setup function applies fassign this way. It first assigns the entire first operand to each file. That establishes all four parts, or all of them that were given in the command, in the file definitions. Then it uses the optional fourth operand of fassign to assign a fixed filetype to each file. That filetype replaces any filetype that might have been given in the command operand. This is how the anthology commands apply a single "antname" operand to the three-file suite of an anthology.
All of the anthology commands except antnew will expect that all three anthology files exist and contain data and will have to open them. These additional formalities I placed in another module, ANTOPE (Figure 9-10).
name 'ANTOPE'
;===============================================================
; ANTOPEn: Open all three anthology files with freset. All must
; have some data in them (even a new, empty anthology has one
; data record and one record in each directory) -- so abort if
; they don't.
dseg
string: strspace 64 ; space for message
empty:
strconst ' is empty or nonexistent.$'
cseg
public ANTOPE
ANTOPE:
push psw
push b
push d
push h
lxi d,AntDat
freset +D
jz abempty
lxi d,AntDirX
freset +D
jz abempty
lxi d,AntDirY
freset +D
jz abempty
pop h
pop b
pop d
pop psw
ret
abempty:
lxi h,string
mvi m,0 ; make it null
fgetspec +H ; filespec to string area
xchg
lxi h,empty
strappnd +H
abort ,+D
Figure 9-10. The code of module ANTOPE.
Any CP/M disk file may be used for direct access. As far as CP/M is concerned, there is no difference between a sequential file and a direct one; they are handled using the same data structures, and direct operations may be mixed with sequential ones. As a result, it is permissible to open a file with freset even though it will be used for direct access. That's what ANTOPE does.
The toolkit contains a function specifically designed for opening direct-access files, fupdate. It does what freset does, but in addition it checks for device files and read-only files and aborts in these cases. The check for a device file (a file assigned a device name like CON: for a filespec) is useful, but not necessary in the anthology programs. They assign a filetype to each anthology file, and by the rules of the toolkit functions, a filespec that includes a filetype (like con:.ant) must be a disk file. Of course, no such file would be found, so that case is trapped without the help of fupdate.
As to the check for a read-only file, some of the anthology commands will attempt to modify the files and some will not. The use of fupdate would have required the files to be read-write, even for the antdir or antget commands. It seemed better to ignore the possibility of a read-only anthology, allowing CP/M to terminate the program if the user attempted to antput to a read-only anthology. Our design is such that doing so can't hurt anything.
The setup function is the only module required to make the antnew command work, and that simple program was the first one implemented (Figure 9-11). It uses setup to set up the files, makes sure that none of them exist, then creates them. The frewrite function creates a file that doesn't already exist.
name 'ANTNEW'
;===============================================================
; ANTNEW antname
; This command creates the 3 files of a new anthology as
; antname.ANT, antname.ANX, and antname.ANY. One (empty) data
; record is written to antname.ANT so that it will be visibly
; in existence. One record of all-zeros is written to both
; directory files. This creates a valid directory containing
; no information. If any file of these names exists, the command
; terminates with an error message and does nothing.
;
; messages:
; usage message
; abort messages of ANTSET
; abort "You must erase the existing anthology first."
; history:
; remember to close the darn files 8/2/84
; initial code 8/1/84
;===============================================================
maclib environ
dseg
record ds 128 ; link will fill with zeros
exists:
strconst 'You must erase the existing anthology first.$'
usage:
db AsciiTAB
db 'ANTNEW antname'
db AsciiCR,AsciiLF
db 'Creates a new anthology as "antname." That operand'
db AsciiCR,AsciiLF
db 'must be a valid filename, and may contain a drive letter'
db AsciiCR,AsciiLF
if CpmLevel gt 22h
db 'and/or a file password'
db AsciiCR,AsciiLF
endif
db 'but a filetype is not necessary and will be ignored.'
db AsciiCR,AsciiLF
db 'Any existing anthology of that name on that drive must'
db AsciiCR,AsciiLF
db 'erased by you before; this command won''t replace it.'
db '$'
cseg
extrn ANTSET
prolog
lxi d,usage
call ANTSET ; returns token-count
cpi 2 ; if we got more than 1,
abort nc,usage ; quit with usage message
lxi d,AntDat
call makeit
lxi d,AntDirX
call makeit
lxi d,AntDirY
call makeit
ret
makeit: ; test DE-+file to see if it exists, and create it
freset +D
abort nz,exists
frewrite +D
fputblok +D,record,128
fclose +D
ret
end
Figure 9-11. The implementation of the antnew command.
After making the a file, antnew writes a block of 128 bytes of binary zero to it. The contents don't matter in the data file, but it must contain at least one record or ANTOPE will reject it. In the directory files, the first six words of the first record will be read to the first six words defined in Figure 9-1. Since they will be zero, they will properly define a directory that contains no information. The other commands will have to be written so that this is an acceptable state for a directory -- as the supporting modules have been.
All the other commands will have to load the anthology's directory from disk. That operation clearly deserved a module of its own, and it got one (Figure 9-12). The job of this load routine is to retrieve the directory from disk and set it up in storage in the form that the supporting functions expect. Four steps are involved. I worked them out in pseudocode, and some of the pseudocode can still be seen in the module.
name 'ANTLOA'
;===============================================================
; Load a directory from disk into storage after ANTSET and
; ANTOPE have done their jobs.
; In contrast to ANTSAV, this module does only the most gross
; validity checks. That is so that, if a directory is only
; slightly damaged, files can still be extracted from it. Any
; inconsistencies in the R and FS arrays will be trapped if
; and when ANTSAV is called to update the directory.
; Since ANTSAV writes a directory in two distinct parts (which
; may have overlapped in storage but are separate on disk) we
; must read it back in two distinct parts. However, we can
; read it from low to high in two simple block-reads.
dseg
msgIC9 db 'Integrity check #9.$'
screen: msgfile
load1 strconst 'Loading directory...'
load2 strconst 'done'
cseg
public ANTLOA
ANTLOA:
push psw
push b
push d
push h
fputstr screen,load1
; fgetblok(ANX,significant numbers)
lxi d,AntDirX
lxi b,AntHdrLen
lxi h,AntHdr
fgetblok +B
; if (SizeR+SizeF + SizeDir) then abort(#9)
lhld SizeR
xchg
lhld SizeF
dad d
lxi b,SizeDir
ora a
dsbc b
jnc IC9 ; must be a garbage file
; Base := DSlow(SizeDir)
; TopR := Base+SizeR
; TopF := Base+SizeDir
; BotF := TopF-SizeF
dslow +B
shld Base
xchg ; save Base in DE
lhld SizeR
dad d ; HL = Base+SizeR
shld TopR
mov h,b ! mov l,c
dad d ; HL = Base+SizeDir
shld TopF
xchg
lhld SizeF
xchg ; HL=TopF, DE=SizeF
ora a
dsbc d ; HL=TopF-SizeF
shld BotF
; seek(ANX,record #2)
lxi d,AntDirX
lxi h,2
frecseek +H
; amount := SpanR*128
; start := Base
; getblock(ANX,start,amount)
lda SpanR ; both spans must be +256
ora a
rar ; 0 + carry
mov b,a
mvi a,0
rar ; carry + A
mov c,a ; BC = SpanR*128
push b ; (save for integrity check)
lhld Base
; n.b. fgetblok handles the case of length=0 ok
fgetblok +B
pop h ; HL=count we asked for
ora a ; BC=count we got
dsbc b ; they better be the same,
jnz IC9 ; ..or something is very wrong
; amount := SpanF*128
; start := TopF-SpanF*128
; getblock(ANX,start,amount)
lda spanF
ora a
rar
mov b,a
mvi a,0
rar
mov c,a ; BC=count of bytes to get
push b ; (save)
ora a
lhld TopF
dsbc b ; HL-+where it goes
fgetblok +B
pop h ; count we asked for
ora a
dsbc b ; ..versus what we got
jnz IC9
; for j := 1 to MaxR do
; if (R[r] + 0) then
; R[r] := TopF - R[r]
; endif
; end for
lhld Base
Ldloop:
lbcd TopR
ora a
dsbc b
jnc Lddone
dad b
mov c,m ! inx h
mov b,m ; BC=R[r], HL-+2nd byte of it
mov a,b ! ora c
jrz Ldstep ; R[r]=0=free slot