home
***
CD-ROM
|
disk
|
FTP
|
other
***
search
/
Programming Tool Box
/
SIMS_2.iso
/
bp_6_93
/
bonus
/
winer
/
chap12.txt
< prev
next >
Wrap
Text File
|
1994-09-03
|
201KB
|
4,258 lines
CHAPTER 12
ASSEMBLY LANGUAGE PROGRAMMING
This book has consistently presented programming techniques that reduce the
size of your programs, and make them run faster. Most of the discussions
focused on ways to write efficient BASIC code, and several showed how to
access system interrupt services. Where speed was critical or BASIC was
inflexible, I presented subroutines written in assembly language.
Assembly language is the most powerful way to communicate with a PC, and
it offers speed and flexibility unmatched by any other language. Indeed,
assembly language is in many ways the ultimate programming language because
it lets you control fully every aspect of your PC's operation. Anything
that a PC is capable of doing can be accomplished using assembly language.
This final chapter explains assembly language in terms that most BASIC
programmers can understand.
Why, you might ask, would a BASIC programmer be interested in assembly
language? After all, the whole point of a high-level language such as
BASIC is to shield the programmer from the underlying hardware. Without
having to worry about CPU registers and memory addresses, a BASIC
programmer can be immediately productive, and probably write programs with
fewer initial bugs. However, there are three important reasons for using
assembly language:
■ To speed up selected portions of a program
■ To reduce the size of a program
■ To perform services that BASIC simply cannot
It is important to understand that any high-level language will benefit
from the appropriate use of assembler. And while it is possible to write
a major application using only assembly language, the increased complexity
and added time to develop and debug it are often not worth the trouble.
Using a high-level language--especially BASIC--for the majority of a
program and then coding the size- and speed-critical portions in assembly
language often is the most practical solution.
Many BASIC programmers mistakenly believe that to achieve the fastest
and smallest programs they should learn C. In my opinion, nothing could
be further from the truth. Assembly language is barely more difficult to
use than C, and in fact the code is often more readable. Further, no
high-level language can come even close to what raw 8086 code can achieve.
If you truly desire to become an advanced programmer, you owe it to
yourself to at least see what assembly language is all about. I believe
there is no deeper satisfaction than that gained by understanding fully
what your computer is doing at the lowest level.
This chapter assumes that you already understand basic programming
concepts such as variables, arrays, and subroutines. As we proceed, most
of the examples will provide parallels to BASIC where possible. But please
remember one important point: There is nothing inherently difficult about
assembly language. Attitude is everything, and if you can think of
assembler as a stripped-down version of BASIC, you will be successful that
much sooner.
For ease of reading, I will refer to the 8088 microprocessor used in the
IBM PC throughout this chapter. However, everything said about the 8088
also applies to the 8086, the 80286, the 80386/486, and the NEC V series
found in some older PC compatible computers. I will also use the terms
assembly language and assembler interchangeably, although assembler can
also be used to mean the program that assembles your source files.
All of the examples in this chapter are meant to be assembled with the
Microsoft Macro Assembler (MASM) version 5.1 or later. MASM requires that
you save your source files as standard ASCII text, and most word processor
programs can do this.
Some of the examples in this chapter are derived from those that used
CALL Interrupt in Chapter 11. In most cases I have not bothered to restate
the same information from that chapter, and you may want to refer back for
additional information.
Finally, many entire books have been written about assembly language,
and there is no way I can possibly teach you everything you need to know
here. Rather, my intent is to provide a gentle introduction to the
concepts using practical and useful examples.
AS EASY AS BASIC
================
Assembly language uses the same general form as a BASIC program. That is,
commands are performed in sequence until a GOTO or GOSUB is encountered.
In assembly language these are called Jump and Call, respectively. Many
BASIC instructions have a direct assembler equivalent, although the syntax
is slightly different. One important difference, however, is that the 8088
microprocessor can operate on integer numbers only. Another is that for
the most efficiency, you are limited to only a few working variables. I
will begin by showing some rudimentary assembly language instructions, so
you can see how they are analogous to similar commands in BASIC. Consider
the following BASIC program fragment:
AX = 5
Here, the value 5 is assigned to the variable AX. The 8088 has several
built-in variables called *registers*, and one of them is called AX. To
move the value 5 into the AX register you use the Mov instruction:
Mov AX,5
As with BASIC, the destination variable in an assembly language program
is always shown on the left, and the source is on the right. Now consider
addition and subtraction. To add the value 12 to AX in BASIC you do this:
AX = AX + 12
The equivalent 8088 command is:
Add AX,12
Again, the variable or register on the left is always the one that receives
the results of any adding, moving, and so on. Subtraction is very similar
to addition, replacing Add with Sub:
BASIC: AX = AX - 100
Assembler: Sub AX,100
Comparing and branching in assembly language is also quite similar to
BASIC. But instead of this:
AX = AX + 2
IF AX > 60 GOTO Finished
You'd do it in assembler this way:
Add AX,2
Cmp AX,60
Ja Finished
This tells the 8088 to add 2 to AX, then compare AX to 60, and finally to
*jump if above* to the code at label Finished. There are several kinds of
conditional jump instructions in assembly language, and they often follow
a comparison as shown here. In fact, all you can really do after a compare
is jump somewhere based on the results. And while there is no direct
equivalent for this BASIC statement:
IF AX = 10 THEN BX = BX - 1
You can change the strategy to this:
IF AX <> 10 GOTO Not10
BX = BX - 1
Not10:
.
.
Now a direct translation is simple:
Cmp AX,10
Jne Not10
Dec BX
Not10:
.
.
Jne stands for *Jump if Not Equal*. Also, notice the command Dec, which
means decrement by 1. This is one case in which an assembler instruction
is actually more to the point than its BASIC counterpart, and is equivalent
to the BASIC command BX = BX - 1. While Sub BX, 1 would work just as well,
using Dec is faster and generates less code, and we all know that speed is
the name of the game.
The complement to Dec is Inc, short for *increment by one*. You can use
Inc and Dec with most of the 8088's registers, as well as on the contents
of any memory location, which brings up an important issue. At some point,
many programs will require more variables than can be held within the CPU's
registers. All of the available free memory in a PC can be used as
variable storage, with only a few limitations:
■ You must first tell the assembler how much space to set aside, much
like you would when dimensioning an array. Moreover, MASM is pretty
friendly and lets you use names for the memory locations. In fact, in
most cases you do not need to know the memory addresses variables will
be stored in--the assembler handles that for you as well!
■ Adding, subtracting, incrementing, and decrementing are all much
faster when done within registers. When an operation is performed on
a memory variable, it must first be fetched by the CPU, manipulated, and
then stored again. Because the registers are within the CPU chip, those
extra steps are not needed. The steps to retrieve and then store memory
variables is handled transparently by the 8088; I mention this merely
to explain why register operations are faster.
■ Some operations can be done only using registers. If you want to
multiply the memory variable Counter by 12, you first have to move the
variable into AX, do the multiplication, and then move it back into
memory again. And if AX is currently holding a needed value, it must
be saved before multiplying and restored again afterward. Although
assembly language is not as complicated as many people think, it surely
can be tedious at times.
Besides the CPU registers and conventional memory addresses, a special
portion of memory called the *stack* is also available for storage. The
stack is much like the temporary memory on a four-function calculator, and
it is often used to store intermediate results. The stack is also commonly
used to pass variables between programs, because all programs can access
it without having to know exactly where in memory it is located. Again,
assembly language doesn't usually require you to deal with absolute memory
addresses at all--especially for subroutines that will be added to a BASIC
program. The only exceptions might be when writing directly to the display
screen, or when looking at low memory, perhaps to see whether the Caps Lock
key is engaged.
SPAGHETTI CODE?
To write a routine that converts lower case letters to capital letters in
BASIC, you might use something like this:
IF AL$ => "a" AND AL$ <= "z" THEN
AL$ = CHR$(ASC(AL$) - 32)
END IF
In assembly language each compare must be done separately, followed by a
jump based on the results. Let's rephrase the BASIC example slightly:
IF AL$ < "a" GOTO Done
IF AL$ > "z" GOTO Done
AL$ = CHR$(ASC(AL$) - 32)
Done:
.
.
Now a conversion to assembler is easy:
Cmp AL,"a" ;compare AL to "a"
Jb Done ;Jump if Below to Done
Cmp AL,"z" ;compare AL to "z"
Ja Done ;Jump if Above to Done
Sub AL,32 ;subtract 32 from AL
Done:
.
.
Notice how the assembler allows the use of quoted constants. When it sees
a character or string in double or single quotes, it knows you mean to use
the character's ASCII value. Unlike BASIC with its strong variable typing
that prevents you from performing numeric operations on a string, assembly
language has very few such restrictions. Also notice how much jumping
around is necessary to accomplish even the simplest of actions.
As I mentioned earlier, assembly language can certainly be more tedious
than BASIC, although the logic is not really that different. Such frequent
jumping around is called spaghetti code by some programmers, and it is
often used in a derogatory fashion when discussing BASIC's GOTO statement.
But this is the way that computers work, and I am amused by programmers who
argue so strongly against all use of the GOTO command. While nobody could
seriously object to a well organized and structured programming style, all
programs are eventually converted to equivalent assembly language jumps and
branches.
THE REGISTERS
=============
There are six general purpose registers available for you to use: AX, BX,
CX, DX, SI, and DI. Each register may be used for the most common
operations like adding and subtracting, although some are specialized for
certain other operations. However, most of the registers also have a
specialty. For example, AX is the only register that can be multiplied or
divided. The A in AX stands for Accumulator, and it often used for math
operations such as accumulating a running total. Also, several assembler
instructions result in one byte less code when used with AX, when compared
to the same instructions using other registers.
The B in BX means Base, and this register is frequently used to hold the
base address of a collection of variables or other data. If you have a
text string in memory to be examined, you could put the address of the
first character in BX. The rest of the string can then be found by
referencing BX.
BX can also be used to specify computed addresses using addition or
subtraction. For example, the instruction Mov AX,[BX+4] means to load AX
with the word four bytes beyond the address held in BX. Likewise, the
instruction Add DL,[BX+SI-10] adds the value of the byte at that computed
address to the current contents of DL. You may use BX this way with either
a constant number, the SI or DI register, or one of those registers and a
constant number. However, only addition and substraction may be used, as
opposed to multiplication or division. I will return to computed and
indirect addressing later in this chapter.
The C in CX stands for Count, since CX is most often used as the counter
in an assembly language FOR/NEXT loop. In fact, the assembly language
command Loop uses CX to perform an operation a specified number of times.
The comparison below illustrates this.
BASIC:
FOR CX = 1 TO 5
GOSUB BeepTone
NEXT
Assembler:
Mov CX,5
Do: Call Beep_Tone
Loop Do
Here, the Loop instruction automatically branches to the label Do: CX
times. That is much faster and more efficient than this:
Mov CX,5
Do: Call Beep_Tone
Dec CX
Cmp CX,0
Jne Do
The DX register is a general purpose Data register, and is named
accordingly. DX is also used in conjunction with AX when multiplying and
dividing.
The last two general purpose registers are SI and DI. SI stands for
Source Index, while DI means Destination Index. It is not hard to guess
that these registers are well suited for copying data from one memory
location to another. The 8088 has a rich set of instructions for moving
and comparing strings, using SI and DI to show where they are.
Like BX, SI and DI may be used with a constant offset such as [SI+100]
to compute a memory address, or with a constant value and/or BX. But
again, SI and DI are still general purpose registers, and they can be used
for common chores as well. In many situations it really doesn't matter
whether you use BX or DI or SI or AX.
There are two specialized registers called BP and SP. BP (Base Pointer)
is another Base register like BX, only it is intended for use with the
stack. When you need to access data on the stack, BP is the most
appropriate register to use. Like BX, BP can reference computed addresses
with a constant offset, with SI or DI, or with a constant and SI or DI.
The SP (Stack Pointer) register holds the current address of the stack,
and it should never be altered unless you have a very good reason to do so.
The last four registers are the segment registers, but I will mention
them only briefly right now. As you undoubtedly know, the 8088 used a
segmented architecture; although it can utilize a megabyte of memory, it
can do so only in 64K portions at a time. The CS register holds the
current Code Segment (your program code), DS holds the Data Segment (your
memory variables), SS holds the Stack Segment, and ES is an Extra Segment
that is often used to access arrays located in far memory.
Each of the 8088 registers can hold one word (two bytes), allowing you
to store any integer number between 0 and 65535. This range of values can
also be considered as -32768 to 32767. But AX, BX, CX, and DX may also be
used as two separate one-byte registers with a range of either 0 to 255 or
-128 to 127. One byte is often sufficient--for example, when manipulating
ASCII characters--and this ability to access each half individually
effectively adds four more registers. Remember, the more variables you can
keep within registers, the faster and more efficient a program will be.
When using the registers separately, the two halves are identified by
the letters H and L, for High and Low. That is, the high portion of AX is
referred to as AH, while the low portion of DX is called DL. This would
be represented with BASIC variables as follows:
AX = AL + 256 * AH
Each half can also be represented as bit patterns:
AX
┌──────────────────────┐
1011 0110 0111 0101
└──────────┘└──────────┘
AH AL
Notice that SI, DI, BP, and SP cannot be split this way, nor can the
segment registers CS, DS, SS, and ES.
There is also another register called the Flags register, though it is
not intended for you to use directly. After performing calculations and
comparisons, certain bits in the Flags register are set or cleared by the
CPU automatically, depending on the results. For example, if you add a
register that holds the value 40000 to another register whose value is
30000, the Carry flag will be set to show that the result exceeded 64K.
The 8088 flags are also set or cleared to reflect the result of a Cmp
(Compare) instruction. Although you will not usually access these flags
directly, they are used internally to process Jne, Ja, and the other
conditional jump commands.
VARIABLES IN ASSEMBLY LANGUAGE
==============================
All of the example routines shown so far have used the 8088 registers as
working variables. Indeed, using registers whenever possible is always
desirable because they can be accessed very quickly. But in many real-
world applications, more variables are needed than can fit into the few
available registers. As with BASIC, MASM lets you define variables using
names you choose, and you must also specify the size of each variable.
The first step is to define the amount of space that will be set aside
with the assembler instructions DB and DW. These stand for Define Byte and
Define Word respectively, and they allocate either one byte of storage or
two. You can also use DD to define a double word long integer variable.
Notice that these are not commands that the 8088 processor will execute;
rather, they inform the assembler to leave room for the data. Some
examples are shown below:
MyByte DB 12h ;one byte, preset to 12h
Buffer DB 15 Dup(0) ;fifteen bytes, all 0
Dummy DW ? ;one word (two bytes), 0
Msg DB "Test message",13,10 ;message, CR, LF
In the first example one byte of memory is allocated using the name MyByte,
and the value 12 Hex is placed there at assembly time. The second example
illustrates using the Dup (duplicate) command, and tells MASM to set aside
fifteen bytes filling each with the specified value. In this case that
value is zero. Initialized data is an important feature of assembly
language, and one that is sorely missing from BASIC. By being able to
allocate data values at assembly time, additional code to assign those
values at runtime is not needed.
Filling an area with zeroes can also be accomplished with a question
mark, and this is frequently used when the value that will eventually end
up there is not known in advance. Both do the same thing in most cases,
however using "?" implies an unknown, as opposed to an explicit zero. You
may use whichever method seems more appropriate at the time. The last
example shows how text may be specified, as well as combining values in a
single statement.
Since the assembler lets you use names for your data, fetching or
storing values can be done with the normal Mov instruction like this.
Error_Code DB ?
Mov Error_Code,AL
This puts the contents of register AL into memory location Error_Code.
Getting it back again later is just as easy:
Mov DH,Error_Code
Sometimes the assembler needs a little help when you assign variables.
When you move AL or DH in and out of a memory location, the assembler knows
that you are dealing with a single byte. And if you specify BX or SI as
the source or destination operand, the assembler understands this to mean
two bytes, or one word. But when literal numbers are used, the size of the
value is not always obvious. Consider the following:
Mov [BX],3Ch
Does this mean that you want to put the value 3Ch into the byte at the
address held in BX, or the value 003Ch into the *word* at that address?
There is no way for MASM to know what your intentions are, so you must
specify the size explicitly. This is done with the Byte Ptr and Word Ptr
directives. Here, Ptr stands for Pointer, and two examples are shown:
Mov Byte Ptr [BX],15
Mov Word Ptr ES:[DI],100
The first example specifies that the memory at address BX is to be treated
as a single byte. Had Word been used instead, a 15 would be placed into
the byte at address held in BX, and a zero would be put into the byte
immediately following. Words are always stored with the low-byte before
the high-byte in memory.
Memory variables are accessed using the normal complement of
instructions. For example, to add 15 to the variable Counter you will use
Add Counter,15. And to multiply AX by the word variable Number you will
use Mul Word Ptr Number. In MASM versions 5.0 and later, the Word Ptr
argument is not strictly necessary. That is, if Number had been defined
using DW, then MASM knows that you mean to multiply by a word rather than
a byte. But earlier versions of the assembler were not so smart, and an
explicit Word Ptr or Byte Ptr was required.
Note, however, that you must still use Byte Ptr or Word Ptr to override
a variable's type. For example, if Value was defined as a word but you
want to access just its lower byte, you must use Mov AL,Byte Ptr Value.
Here, stating Byte Ptr explicitly tells MASM that you are intentionally
treating Value as a different data type. Otherwise, it will issue a non-
fatal warning error message.
Sometimes you may want to refer to the address of a variable, as opposed
to its contents. For example, Mov AX,Variable tells MASM to move the value
held in Variable into the AX register. But many DOS services require that
you specify a variable's address in a register. This is done using the
Offset operator: Mov DX,Offset Buffer. Where Mov DX,Buffer places the
first two bytes of the buffer into DX, using Offset tells MASM that you
instead want the starting address of the buffer.
You can also use the Lea (Load Effective Address) command to obtain an
address, but that is less frequently used. Although Lea DX,Buffer can be
used to load DX with the starting address of Buffer, it is a slightly
slower instruction. Lea is needed only when an address must be computed.
For example, the instruction Lea SI,[BX+DI] loads SI with the sum of the
BX and DI registers. You may notice that Lea can provide a shortcut for
adding or subtracting certain register combinations. Although this use of
Lea is uncommon, Lea can replace the following two instructions:
Mov SI,BX
Add SI,DI
To subtract two registers or a register and a constant value you could use
Lea AX,[BX-DI] or Lea SI,[BP-10].
CALCULATIONS IN ASSEMBLY LANGUAGE
=================================
When adding or subtracting you may use two registers, or a register and a
memory variable. It is not legal to specify two memory variables as in
Add Var1,Var2.
Multiplying and dividing are not so flexible; only AL and AX may be
multiplied. When dividing, the numerator must be either in AX, or the long
integer comprised of DX:AX. In this case, DX holds the upper word and AX
holds the lower one. However, you may multiply or divide these registers
using either a register or a memory location. Because of this restriction,
it is not necessary to specify the target operand size. That is, Mul CL
means to multiply AL by CL leaving the result in AX, and Div WordVariable
divides DX:AX by the contents of WordVariable leaving the result in AX and
the remainder in DX. Although you could use the commands Mul AL,CL and Div
AX,WordVariable, this is not necessary or common.
All of the allowable combinations for multiplying and dividing are shown
in Figure 12-1.
Instruction Operand Result Remainder
════════════════ ═══════ ══════ ═════════
Mul ByteRegister AL AX n/a
Mul ByteVariable AL AX n/a
Mul WordRegister AX DX:AX n/a
Mul WordVariable AX DX:AX n/a
Div ByteRegister AX AL AH
Div ByteVariable AX AL AH
Div WordRegister DX:AX AX DX
Div WordVariable DX:AX AX DX
Figure 12-1: The allowable register/memory combinations for multiplying and
dividing.
In Figure 12-1 ByteRegister means any byte-sized register such as AL or
CH; WordRegister indicates any word-sized register like CX or BP.
Likewise, ByteVariable and WordVariable specify byte- and word-sized
integer memory variables respectively.
It's important to understand that you must never divide by zero, because
that will generate a critical error. Because the result from dividing by
zero is infinity, the 8088 has no way to handle that--it can't simply
ignore the error. Therefore, dividing by zero causes the CPU to generate
an Interrupt 0. In a BASIC program that error is routed to BASIC's
internal error handling mechanism which either invokes the ON ERROR handler
if one is in effect, or ends your program with an error message. In a
purely assembly language program, DOS intervenes printing an error message
on the screen, and then it ends the program.
Related to division by zero is dividing when the result cannot fit into
the destination register. For example, if AX holds the value 20000 and you
divide it by 2, the resulting 10000 cannot fit into AL. Since this is
another unrecoverable error that cannot be ignored, the 8088 generates an
Interrupt 0 there as well.
Besides the Div and Mul instructions, there are also signed versions
called Idiv and Imul. Where Div and Mul treat the contents of AX or DX:AX
as an unsigned value, Idiv and Imul treat them as being signed. You'll use
whichever command is appropriate, so the 8088 knows if values having their
highest bit set are to be treated as negative. BASIC always uses Idiv and
Imul in the code it generates, since all integer and long integer values
are treated by BASIC as signed.
Because only AX and DX:AX may be used for multiplying and dividing, this
affects your choice of registers. The short example that follows shows how
you might select registers when translating a simple BASIC-like expression
that uses only integer (not long integer) variables.
BASIC:
Result = (Var1 + Var2 * (Var3 - Var4)) \ 100
Assembler:
Mov AX,Var3 ;work from the innermost level out
Sub AX,Var4 ;so first perform Var3 - Var4
Imul Word Ptr Var2 ;then multiply that by Var2
Add AX,Var1 ;add Var1 to what we have so far
Mov DX,0 ;next prepare to divide DX:AX
Mov CX,100 ;use CX for the divisor
Idiv CX ;do the division
Mov Result,AX ;then assign Result ignoring the
; remainder left in DX
Because dividing by an integer value uses both DX and AX, it is necessary
to clear DX explicitly as shown unless you are certain it is already zero.
The use of CX to hold the value 100 is arbitrary. If CX were currently in
use, any available word-sized register or memory location could be used.
If you compile this program statement and view the resultant code using
CodeView, you will see that BASIC does an even better job of translating
this particular expression to assembly language.
STRING PROCESSING INSTRUCTIONS
==============================
Besides being able to add, subtract, multiply, and divide, the 8088
provides four very efficient instructions for manipulating strings and
other data in memory. Movs copies, or moves a string from place to
another; Cmps compares two ranges of memory; Stos fills, or stores one or
more addresses with the same value; and Scas scans a range of memory
looking for a particular value. These instructions require either a byte
or word specifier. For example, you would use Movsb to copy a byte, and
Cmpsw to compare two words.
There are two important factors that contribute to the power and
usefulness of these string instructions: each is only one byte long, and
they automatically increment or decrement the SI and DI registers that
point to the data being manipulated. Thus, they are both convenient to
use, and also very fast. Because it is common to access blocks of memory
sequentially a byte or word at a time, automatically advancing SI and DI
saves you from having to do that manually with additional instructions.
For example, after one pair of words has been compared, SI and DI are
already set to point at the next pair.
You can also specify that SI and DI are to be decremented by first using
the Std (Set Direction) command. The Direction Flag stores the current
string operations direction, which is either up or down. If a previous Std
was in effect, then you'd use Cld (Clear Direction) to force copying and
moving to be forward. In fact, BASIC *requires* you to clear the direction
flag to forward before returning from a routine that set it to backwards.
MOVS AND CMPS
Movs and Cmps use the DS:SI register pair to point to the first range of
memory being copied or compared, and ES:DI to point to the second range.
Each time a byte is being copied or compared, SI and DI are incremented or
decremented by one to point to the next address. And when a word is being
accessed, SI and DI are incremented or decremented by two.
Notice that there is no protection against SI or DI being incremented
or decremented through address zero, nor is there any indication that this
has happened. Also notice that the name Movs is somewhat of a misnomer.
To me, moving something implies that it is no longer at its original
location. Movs does not alter the source data at all--it merely places a
new copy at the specified destination address.
SCAS AND STOS
Scas compares the value in AL or AX with the range of memory pointed to
by ES:DI. That is, Scasb compares AL and Scasw uses AX. Stos also uses
ES:DI to show where the data being written to is located; Stosb stores the
contents of AL in the address at ES:[DI] and then increments or decrements
DI by one. Likewise, Stosw stores the value in AX there and increments or
decrements DI by two.
REPEATING STRING OPERATIONS
If these four instructions merely acted on the data and incremented SI and
DI automatically, that would be very useful indeed. But they also have
another talent: they recognize a Rep (Repeat) prefix to perform their magic
a specified number of times. The number of iterations is specified by the
count held in CX. Furthermore, the number of repetitions can be made
conditional when comparing and scanning, based on the data encountered.
If you have, say, 20 bytes of data that need to be copied from one place
to another, you would first set CX to 20 and then use Rep Movsb. And to
compare 100 words you would load CX with the value 100 and use Rep Cmpsw.
Stos also accepts a Rep prefix; Rep Stosb places the value in AL into CX
bytes of contiguous memory starting at the address specified in ES:DI. For
each iteration the 8088 decrements CX, and when it reaches zero the copying
or comparing is complete.
It is usually not valuable to scan a range of memory unconditionally and
repeatedly. Therefore Scas is generally used in conjunction with either
Repe (Repeat while Equal) or Repne (Repeat while Not Equal). Cmps is also
generally used with these conditional prefixes, to avoid wasting time
comparing bytes after a match or a difference was found. In either case,
however, you load CX with the total number of bytes or words being compared
or scanned.
Because each iteration decrements CX, you can easily calculate how many
bytes or words were actually processed. Also, you can test the results of
scanning and comparing using the normal methods such as Je and Jne. The
following few examples show some ways these commands can be used.
See if two 40-byte ranges of memory are the same:
Mov CX,20 ;comparing 20 words is faster than 40 bytes
Repe Cmpsb ;compare them
Je Match ;they matched
Copy a 2000-element integer array to color screen memory:
Mov AX,ArraySeg ;set DS to the source segment
Mov DS,AX ;through AX
Mov SI,ArrayAdr ;point SI to the array start
Mov AX,&HB800 ;the color text screen segment
Mov ES,AX ;assign that to ES
Mov DI,0 ;clear DI to point to address 0
Mov CX,2000 ;prepare to copy 2000 words
Rep Movsw ;copy the data
Search a DOS string looking for a terminating zero byte:
Mov AX,StringSeg ;set ES to the string's segment
Mov ES,AX ;(ES cannot be assigned directly)
Mov DI,Offset ZString ;point DI to the string data
Mov CX,80 ;search up to 80 bytes
Mov AL,0 ;looking for a zero value
Repne Scasb ;while ES:[DI] <> AL
;-- Now DI points just past the terminating zero byte.
;-- The length of the string is (80 - CX + 1).
In the first example, it is assumed that DS:SI and ES:DI already point to
the correct segment and address. By asking to compare only while the bytes
are equal, the result of the most recent byte comparison can be tested
using Je. A common mistake many programmers make is comparing the bytes,
and then checking if CX is zero. The reasoning is that if CX is zero then
they must have all matched; otherwise, the 8088 would have aborted the
comparisons early. But CX will also be zero if all but the last byte
matched! Therefore, you must check the zero flag using Je (or Jne if that
is more appropriate).
Notice in the first example how 20 words are compared, rather than 40
bytes. Although the net result is the same, word operations are faster on
80286 and later processors when the blocks of memory begin at an even
numbered address. [Though you can't always know if a variable or block
of memory will begin at an even address, using the word version will be
more efficient at least some of the time.]
The second and third examples include the code needed to set up the
appropriate segment and address values in DS:SI and ES:DI. Although this
may seem like a lot of work, you can often do this setup only once and then
use the same registers repeatedly within a routine. Unfortunately, you
are not allowed to assign a segment register from a constant number. You
must first assign the number to a conventional register, and then use Mov
to copy it to the segment register.
THE STACK
The primary purpose of the stack is to retain the return address of a
program when a subroutine is called. This is true not only for assembly
language, but for BASIC as well. For example, when you use the BASIC
statement GOSUB 1200, BASIC must remember the location in memory of the
next command to execute when the routine returns. It does this by placing
the address of the next instruction onto the stack *before* it jumps to the
subroutine. Then when a RETURN instruction is encountered, the address to
return to is available. The 8088 understands Calls and Returns directly,
and it places and restores the addresses on the stack automatically.
The stack is not unlike a stack of books on a table, and one of its
great advantages is that you don't need to know where in memory it is
actually located. Items can be placed onto the stack either manually with
the Push instruction, or automatically by the 8088 processor as part of its
handling of Call and Return statements. Values are retrieved from the
stack with the Pop command, among other methods.
One important feature of the stack is when items are added and removed,
the stack pointer register is updated automatically to reflect the next
available stack location. Thus, a program can access items on the stack
based on the stack pointer, rather than have to know the exact address at
any given time. This simplifies exchanging information between programs,
since neither has to know how the other operates. This mechanism also
makes it possible for programs written in one language to communicate with
subroutines written in another.
Figure 12-2 shows how the stack operates.
│
│
│
│
├──────────┤
│ Item 1 │ <── first item that was pushed
├──────────┤
│ Item 2 │ <── second item that was pushed
├──────────┤
│ Item 3 │ <── third item that was pushed
├──────────┤
│ Item 4 │ <── last item that was pushed (SP points here)
├──────────┤
│ Next │ <── next available stack location
├──────────┤
│ ┌── the stack grows downward
│ │ as new items are added
│ │
│ │
\/
Figure 12-2: The organization of the CPU stack.
As each item is pushed onto the stack, it is placed two bytes below the
address held in the stack pointer. Then the stack pointer is decremented
by two, to show the next available stack location. Therefore, the stack
grows downward as new items are added. Note that only full words may be
pushed onto the stack, so all of the items shown here are two bytes in
size. Also note that the stack pointer holds the address of the last item
that was pushed.
PASSING PARAMETERS
==================
Imagine you have a BASIC subroutine that does something to the variable X.
The code to assign X, process, and print X might look like this:
X = 12
GOSUB 2000 'the routine at line 2000 manipulates X
PRINT X
In assembly language you could push the value 12 onto the stack, and then
call the subroutine. The subroutine, expecting the value there would
retrieve it, do its work, and then place the result back again before
returning. This is similar, but not identical, to how variables are passed
between programs. Most high-level languages including BASIC pass variables
to subroutines by placing their *addresses* on the stack. A called routine
can then access the variable via its address, either to read it or to
assign a new value.
If BASIC let you access the registers directly, it could pass variables
through them, as you saw when telling DOS which of its services to do. But
BASIC doesn't allow that and moreover, with a limited number of registers,
only a few variables or addresses could be accommodated. The stack can
hold any number of arguments, by pushing the address of each in turn.
When you use the BASIC CALL command and pass a variable name to a SUB
or FUNCTION procedure, BASIC first pushes the address of that variable onto
the stack, before jumping to the code being called. And if more than one
variable is specified, all of the addresses are pushed. The example below
shows how you might call a routine that returns the current default drive.
CALL GetDrive(Drive%)
When GetDrive begins, it knows that the stack is holding the address of
Drive%. The segment and address of the calling BASIC program is also on
the stack; however, GetDrive is not concerned with that. The important
point is that it can find the address on the stack using the SP (Stack
Pointer) register. When GetDrive begins the stack is set up as shown in
Figure 12-3.
│ ^
│ │
│ │
│ └── higher addresses
├──────────┤
│ Drive% │ <── the address of Drive% that BASIC pushed
├──────────┤
│ Ret Seg │ <── BASIC's segment to return to
├──────────┤
│ Ret Adr │ <── BASIC's address to return to (SP holds this address)
├──────────┤
│ Next │ <── the next available stack location
├──────────┤
│
│
│
│
Figure 12-3: The state of the stack within a procedure when one variable
address was passed.
Notice that while GetDrive can get at the address of Drive% through SP,
an extra step is still required to get at the *data* held in Drive%. Let's
digress for a moment to reconsider the difference between memory addresses
and values. The assembler command Mov AX,12 puts the value 12 into
register AX. But suppose you want to put the contents of *memory location*
12 into AX. You indicate this to the assembler by using brackets, as shown
in the two equivalent examples following.
Mov AX,[12] ;load AX from address 12
Mov BX,12 ;assign BX to the value 12
Mov AX,[BX] ;load AX from the address held in BX
The first statement loads AX from the contents of memory at address 12.
The second first loads BX with the number 12, and then uses BX to identify
that address, moving the contents of that address into AX. This is an
important distinction, and is illustrated in Figure 12-4 using parallels
to BASIC's PEEK and POKE commands.
BASIC Assembler
════════════════════ ═════════════════════
BP = SP Mov BP,SP
AL = PEEK(BP + 8) Mov AL,[BP+8]
SI = 12 Mov SI,12
POKE SI, 12 Mov Byte Ptr [SI],12
Figure 12-4: Similarities between BASIC's PEEK and POKE, and the assembly
language Mov instruction.
Although you can easily find the address of Drive% by looking at SP, an
extra step is required to get at the actual value. The example that
follows shows how to do this, except there is one added complication. You
are not allowed to use SP for addressing, except with 386 and later
microprocessors. Since you undoubtedly want your programs to work with as
many computers as possible, a different strategy must be used.
As I mentioned earlier, the BP register is a base register that is meant
for accessing data on the stack. Therefore, you must first copy SP into
BP, and then use BP to access the stack. Then you can find where Drive%
is located, and put the current drive number into that address as shown
following:
Mov BP,SP ;put the current stack pointer into BP
Mov SI,[BP+4] ;put the address of Drive% into SI
Mov AH,19h ;tell DOS we want the default drive
Int 21h ;call DOS to do it
Mov [SI],AL ;put the answer into Drive%
Notice how brackets are used to indicate the addresses. You must first
determine the address of Drive%'s address (whew!), before you can put the
value held in AL there. This is called indirect addressing, because a
register is used to hold the address of the data. Again, notice how the
8088 accepts addition on the fly when you tell it BP+4.
The complete working GetDrive routine has two small added complications.
Beside being unable to use SP for addressing memory, BASIC also requires
you to not change BP either. The obvious solution, therefore, is to first
save BP on the stack before changing it, and then restore BP later before
returning to BASIC. The other complication is caused by the very fact that
BASIC put extra information (Drive%'s address) onto the stack. But neither
is insurmountable, as shown here:
Push BP ;save BP before changing it
Mov BP,SP ;put the stack pointer into BP
Mov SI,[BP+6] ;put the address of Drive% into SI
Mov AH,19h ;tell DOS we want default drive
Int 21h ;call DOS to do it
Mov [SI],AL ;put the answer into Drive%
Pop BP ;restore BP to its original value
Ret 2 ;return to BASIC
Notice that here, the address of Drive% is at [BP+6] rather than [BP+4]
as it was in the previous listing. Since BP was pushed at the start of the
procedure, the stack pointer is two bytes lower when it is subsequently
assigned to BP. When SI is loaded, [BP] points to the saved version of
itself, [BP+2] and [BP+4] point to the address and segment to return to,
and [BP+6] holds the address of Drive%'s address. This is illustrated in
Figure 12-5.
│
│
│
│
├──────────┤
│ Drive% │ <── [BP+6] points here
├──────────┤
│ Ret Seg │ <── [BP+4] points here
├──────────┤
│ Ret Adr │ <── [BP+2] points here
├──────────┤
│ Saved BP │ <── [BP] points here
├──────────┤
│ Next │ <── the next available stack location
├──────────┤
│
│
│
│
Figure 12-5: The state of the stack within a procedure after BP has been
pushed.
Normally when a Ret command is encountered, the 8088 pops the last four
bytes from the stack automatically, and returns to the segment and address
contained in those bytes. But that would leave the 2-byte address of
Drive% still cluttering up the stack. To avoid this problem the 8088 lets
you specify a *parameter count* as part of the Ret instruction.
For each variable address that is passed with a CALL from BASIC, you
must add 2 to the Return instruction in your assembler routine. This is
the number of bytes to remove from the stack, with two being used for each
incoming two-byte address. Had two variables been passed, the program
would have used Ret 4 instead. Although it is possible to have the calling
program clean up the stack itself, that would be wasteful.
For every occurrence of every call that passes parameters, BASIC would
have to include additional code following the call to increment SP
accordingly. Pushing a parameter's address onto the stack leaves that much
less stack space available. Therefore, someone has to reverse the process
and either pop the addresses or use Add SP,Num to adjust the stack pointer.
By having the called routine handle it, that code is needed only once. In
fact, this is an important deficiency of C, because by design C requires
the caller to clean up the stack.
[If you've managed to persevere this far you'll be pleased to know that
in practice, the assembler can be told to handle most or all aspects of
stack addressing for you. This is discussed in the sections that follow.]
It is also possible to tell BASIC to pass some types of parameters by
value using the BYVAL option in the DECLARE or CALL statements. When BYVAL
is used, BASIC places the actual value of the variable onto the stack,
rather than its address. This has several important benefits. First, the
assembly language routine can use one less instruction. Second, when a
constant number is passed, BASIC does not need to make a copy of it in
DGROUP. This copying was described in Chapter 2.
However, BYVAL is appropriate only when a parameter does not have to be
returned, and only when the values are integers. If you pass a double
precision parameter using BYVAL, all eight bytes are placed on the stack
using four separate instructions rather than only two needed to pass the
address. You can also instruct BASIC to pass the full, segmented address
of a parameter, and that is discussed in the section "Dynamic Arrays."
PROCEDURES IN ASSEMBLY LANGUAGE
===============================
All of the discussions so far have focused on how to write the instructions
for an assembly language subroutine. However, none have described how
these routines are added to a BASIC program, or how a complete procedure
is defined. Furthermore, the previous examples have not shown a key step
that is needed with all such external routines: establishing the code and
data segments.
Before an external routine can be linked to a BASIC program you must
establish a public procedure name that LINK can identify. I will first
show the formal method for defining a procedure and its segments, and then
show the newer, simplified methods that were introduced with MASM version
5.1. The simplified syntax is used for all of the remaining examples in
this chapter [so don't worry if the setup details for this first example
appear overwhelming].
The simplest complete subprogram you are likely to encounter is probably
the PrtSc routine that follows--all it does is call Interrupt 5 to send the
contents of the current display screen to LPT1.
Code Segment Word Public 'Code'
Assume CS:Code
Public PrtSc
PrtSc Proc Far ;this is equivalent to SUB PrtSc STATIC in BASIC
Int 5 ;call BIOS interrupt 5
Ret ;return to BASIC
PrtSc Endp ;this is equivalent to BASIC's END SUB
Code Ends
End
The first three lines tell the assembler that the code is to be placed in
the segment named Code, and that the name PrtSc is to be made public. The
fourth line defines the start of a procedure. The actual code occupies
the next two lines. Of course, you must tell the assembler where the
procedure ends, which in this case is also the end of the code segment.
Had several procedures been included within the same block of code, each
procedure would show a start and end point, but there would only be a
single code segment. The final End statement is needed to tell the
assembler that this is the end of listing, although you might think that
MASM would be smart enough to figure that out by itself!
Notice that there are two kinds of procedures: Far and Near. External
routines that are called from BASIC are always Far, because BASIC uses what
is called a *medium model*. This means the procedure does not necessarily
have to be within the same code segment as the main BASIC program. The
medium model allows the combined programs to exceed the usual 64k limit
when linked to a final .EXE file.
When BASIC executes a CALL command, it uses a two-word address as the
location to jump to. One of the words contains a segment, and the other
an address within that segment. Then when your program finally returns,
the 8088 must know to remove two words from the stack--a segment and an
address--to find where to return to in the calling BASIC program.
A near procedure, on the other hand, calls an address that is only one
word long. And when the procedure returns, only a single word is popped
from the stack. Again, the assembler does the bulk of the dirty work for
you. You just have to remember to use the word Far.
SIMPLIFIED DIRECTIVES
Fortunately, Microsoft realized what a pain dealing with segments and
procedures and offsets from BP can be, and they enhanced MASM beginning
with version 5.0 to handle these details automatically for you. Rather
than require the programmer to define the various code and data segments,
all that is needed are a few simple key words.
The first is .Model Medium, which tells MASM that the procedures that
follow will be Far. Used in conjunction with .Code and .Data, .Model
Medium tells MASM that any data you define should be placed into a group
named DGROUP. Adding ,Basic after the .Model directive also declares your
procedures as Public automatically, so BASIC can access them when your
program is linked.
By using the name DGROUP, the linker automatically gathers all of your
DB and DW data variables, and places them into the same segment that BASIC
uses. While this has the disadvantage of impinging on BASIC's near data
space, it also means that on entry to the routine the DS register (which
BASIC sets to hold the DGROUP segment) hold the correct segment value for
your variables as well.
To show the advantages of simplified directives, contrast the earlier
PrtSc with this version that does exactly the same thing:
.Model Medium, Basic
.Code
PrtSc Proc
Int 5
Ret
Endp
End
MASM 5.1 introduced additional simplified directives that let you access
incoming parameters by name, rather than as offsets from BP. All of the
remaining examples in this chapter take advantage of simplified directives,
as the following revised listing for GetDrive illustrates.
;Syntax: CALL GetDrive(Drive%)
.Model Medium, Basic
.Data
;-- if variables were needed they would be placed here
.Code
GetDrive Proc, Drive:Word
Mov AH,19h ;tell DOS we want the default drive
Int 21h ;call DOS to do it
Mov BX,Drive ;put the address of Drive% into BX
Cbw ;clear AH to make a full word
Mov [BX],AL ;then store the answer into Drive%
Ret ;return to BASIC
GetDrive Endp ;indicate the end of the procedure
End ;and the end of the source file
As you can see, this looks remarkably like a BASIC SUB or FUNCTION
procedure, with the incoming parameter listed by name and type as part of
the procedure declaration. This greatly simplifies maintaining the code,
especially if you add or remove parameters during development. If incoming
parameters are defined as shown here using Drive%, code to push BP and then
move SP into BP is added for you automatically. When you refer to one of
the parameters, the assembler substitutes [BP+##] in the code it generates.
Note, however, that the Word identifier for Drive refers to the 2-byte size
of its address, and not the fact that Drive% is a 2-byte integer.
Also notice the new Cbw command, which is used here to clear the AH
register. Cbw (Convert Byte to Word) expands the byte value held in AL to
a full word in AX. A full word is needed to ensure that both the high- and
low-byte portions of Drive% are assigned, in case it held a previous value.
If the value in AL is positive (between 0 and 127), AH is simply cleared
to zero. And if AL is negative (between -128 and -1 or between 128 and
255), Cbw instead sets all of the bits in AH to be on. Thus, the sign of
the original number in AL is preserved.
A complementary statement, Cwd (Convert Word to Double Word), converts
the word in AX to a double-word in DX:AX. Again, if AX is positive when
considered as a signed number, DX is cleared to zero. And if AX is
currently negative, DX is set to FFFFh (-1) to preserve the sign. Cbw and
Cwd are both one-byte instructions, so even with unsigned values they are
always smaller and faster for clearing AH or DX than Mov AH,0 and Mov DX,0
which require two bytes and three bytes respectively.
Finally, the Ret command that exits the procedure is translated by MASM
to include the correct stack adjustment value, based on the number of
incoming parameters. If you have multiple exit points from the procedure
(equivalent to EXIT SUB), the exit code will be generated multiple times.
That is, each occurrence of Ret is replaced with a code sequence to pop the
saved registers, and preform the 3-byte Ret # instruction. Therefore, you
should always use a single exit point in a routine, and jump to that when
you need to exit from more than one place.
CALLING INTERRUPTS
==================
Chapter 11 explained how interrupts work, and mentioned that only assembly
language can call an interrupt directly. An assembler program uses the Int
instruction, and this tells the 8088 to look in the interrupt vector table
in low memory to obtain the interrupt procedure's segment and address.
Then the procedure is called as if it were a conventional subroutine.
All of the DOS and BIOS services are accessed using interrupts, though
there are so many different services that you also have to pass a service
number to many of them. Most of the DOS services are accessed through
interrupt 21h. Where BASIC uses the &H prefix to indicate a hexadecimal
value, assembly language uses a trailing letter H. If you specify a number
without an H it is assumed by MASM to be regular decimal. Note that MASM
doesn't care if you use upper- or lowercase letters, and knows that either
means hexadecimal.
When specifying hexadecimal values to MASM, the first character must
always be a digit. That is, 1234h is acceptable, but &HB800 must be
entered as 0B800h. Using B800h will generate a syntax error.
DOS AND BIOS SERVICES
You have already seen how to call the BIOS routine that prints the screen
and the DOS routine that returns the current drive. Let's continue and see
how to call some of the other useful routines in the BIOS and DOS.
The next example program, DosVer, shows how to call the DOS service that
returns the DOS version number. Like many of the assembler routines that
you can use with BASIC, DosVer relies on an existing DOS service to do the
real work. In this program you will also learn how to push and pop values
on the stack.
The syntax for DosVer is CALL DosVer(Version%), where Version% returns
with the DOS version number times 100. That is, if your PC is running DOS
version 3.30, then Version% will be assigned the value 330. Manipulating
floating point numbers is much more difficult than integers, and the added
complexity is not justified for this routine.
The DOS service that retrieves the version number returns with two
separate values--the major version number (3 in this case) and the minor
number (30). These values are returned in AL and AH respectively. The
strategy here is to first multiply AL by 100, and then add AH. The last
step is to assign the result to the incoming parameter Version%.
Unfortunately, when you use AL for multiplication, the value 100 must
be in a register or memory location. You can't just use MUL AL,100 though
it would sure be nice if you could. Further, whenever AL is multiplied the
result is placed into the entire AX register. Therefore, DosVer also uses
BX to temporarily store the original contents of AX before the two are
added together.
As you already have learned, the only register that can be multiplied
is AX, or its low-byte portion, AL. MASM knows if you plan to multiply AX
or AL based on the size of the argument. For example, Mul BX means to
multiply AX by BX and leave the result in DX:AX. Mul CL instead multiplies
AL by CL and leaves the answer in AX.
The complete DosVer routine is shown following, and comments explain
each step.
;DOSVER.ASM, retrieves the DOS version number
.Model Medium, Basic
.Code
DOSVer Proc, Version:Word
Mov AH,30h ;service 30h gets the version
Int 21h ;call DOS to do it
Push AX ;save a copy of the version for later
Mov CL,100 ;prepare to multiply AL by 100
Mul CL ;AX is now 300 if running DOS 3.xx
Pop BX ;retrieve the version, but in BX
Mov BL,BH ;put the minor part into BL for adding
Mov BH,0 ;clear BH, we don't want it anymore
Add AX,BX ;add the major and minor portions
Mov BX,Version ;get the address for Version%
Mov [BX],AX ;assign Version% from AX
Ret ;return to BASIC
DOSVer Endp
End
Notice the extra switch that is done with BH and BL. AX is saved onto the
stack because multiplying the byte in AL leaves the result as a full word
in AX, thus destroying AH. When the version is popped into BX, the minor
part is in BH. But you are not allowed to add registers that are different
sizes (AX and BH). Further, any number in the high half of a register is
by definition 256 times the value of the same number in a low half.
Therefore, BH is first copied to BL to reflect its true value. BH is then
cleared so it won't affect the result, and finally AX and BX are added.
A better way to save AX and then restore it to BX would be to simply use
Mov BX,AX immediately after the call to Interrupt 21h. I used Push and Pop
just to show how this is done. As you can see, it is not necessary to pop
the same register that was pushed. However, every Push instruction must
always have a corresponding Pop, to keep the stack balanced. If a register
or other value is on the stack when the final Ret is encountered, that
value will be used as the return address which is of course incorrect.
Division also acts on AX, or the combination of DX:AX. When you use
the command Div BL, the 8088 knows you want to divide AX because BL is a
byte-sized argument. It then leaves the result in AL and the remainder,
if any, is placed into AH. Similarly, Div DX means that you are dividing
the long integer in DX:AX, because DX is a word. The result of this
division is assigned to AX, with the remainder in DX.
ACCESSING BASIC STRINGS IN ASSEMBLY LANGUAGE
============================================
As Chapter 2 explained, strings are stored very differently than regular
numeric variables. BASIC lets you find the address of any variable with
the VARPTR function. For integer or floating point numbers, the value
VARPTR returns is the address of the actual data. But for strings, VARPTR
instead returns the address of a string descriptor.
DOS employs a different method entirely for its strings, using a CHR$(0)
to mark the end. This is describes separately later in the section "DOS
Strings."
BASIC NEAR STRINGS
A BASIC string descriptor is a table containing information about the
string--that is, its length and address. In Microsoft compiled BASIC a
string descriptor is comprised of two words of information. For QuickBASIC
and near strings when using BASIC PDS, the first word contains the length
of the string and the second holds the address of the first character.
Consider the following BASIC instructions:
X$ = "Assembler"
V = VARPTR(X$)
V now holds the starting address of the four-byte descriptor for X$. For
the sake of argument, let's say that V is now 1234. Addresses 1234 and
1235 will together contain the length of X$ which is 9, and addresses 1236
and 1237 will contain yet another address--that of the first character in
X$. You can therefore find the length of X$ using this formula:
Length = PEEK(V) + 256 * PEEK(V + 1)
And the first character "A" can be located with this:
Addr = PEEK(V + 2) + 256 * PEEK(V + 3)
You could then print the string on the screen like this:
FOR C = Addr TO Addr + 8
PRINT CHR$(PEEK(C));
NEXT
Therefore, this is a BASIC model for how strings are located by an assembly
language program. When you call an assembler routine with a string
argument, BASIC first pushes the address of the descriptor onto the stack,
before calling the routine. The next example is called Upper, because it
capitalizes all of the characters in a string. Even though BASIC offers
the UCASE$ and LCASE$ functions, these are relatively slow because they
return a copy of the data that has been manipulated. Upper instead
capitalizes the data in place very quickly.
The strategy is to first get the descriptor address from the stack.
Then Upper puts the length into BX and the address of the string data into
SI. Upper steps through the string starting at the end, decrementing BX
by one for each character. When BX crosses zero, it is done. A BASIC
version is shown first, followed by the assembly language equivalent.
Upper in BASIC:
SUB Upper(Work$) STATIC
'-- load SI with the address of Work$ descriptor
SI = VARPTR(Work$)
'-- assign LEN(Work$) to BX
BX = PEEK(SI) + 256 * PEEK(SI + 1)
'-- the address of the first character goes in SI
SI = PEEK(SI + 2) + 256 * PEEK(SI + 3)
More:
BX = BX - 1 'point to the end of Work$
IF BX < 0 GOTO Exit 'no more characters to do
AL = PEEK(SI + BX) 'get the current character
IF AL < ASC("a") GOTO More 'skip conversion if too low
IF AL > ASC("z") GOTO More 'or if too high
AL = AL - 32 'convert to upper case
POKE SI + BX, AL 'put character back in Work$
GOTO More 'go do it all again
Exit: 'return to caller
END SUB
Upper in assembly language:
Upper Proc, Work:Word
Mov SI,Work ;load SI with Work$'s descriptor address
Mov BX,[SI] ;put LEN(Work$) into BX
Mov SI,[SI+2] ;SI holds address of the first character
Next:
Dec BX ;point to the next prior character
Js Exit ;if sign is negative BX is less than 0
Mov AL,[BX+SI] ;put the current character into AL
Cmp AL,"a" ;compare it to ASC("a")
Jb More ;jump if below to More
Cmp AL,"z" ;compare AL to ASC("z")
Ja More ;jump if above to More
Sub AL,32 ;convert AL to upper case
Mov [BX+SI],AL ;put AL back into Work$
Jmp More ;jump to More
Exit:
Ret ;return to BASIC
Upper Endp
End
What's Your Sign?
Notice that for expediency, these routines work backwards from the end of
the string. There are a number of shortcuts that you can use in assembly
language, and one important one is being able to quickly test the result
of the most recent numeric operation. If the program worked forward
through the string, it would take three lines of code to advance to the
next character, and also require saving the string length separately:
Inc BX ;point to the next character
Cmp BX,Length ;are we done yet?
Jne More ;no, continue
Notice the use of a new form of conditional jump--Js which stands for *Jump
if Signed*. Here the code tests the sign of the number in BX, and jumps
if it is negative. Though I haven't mentioned this yet, a conditional jump
doesn't always have to follow a compare. Although a comparison will set
the flags in the 8088 that indicate whether a particular condition is true,
so will several other instructions. Some of these are Add, Sub, Dec, and
Inc, but not Mov. So instead of having to include an explicit comparison:
Dec BX ;decrement BX
Cmp BX,0 ;compare it to zero
Jl More ;jump if less to More
All that is really needed is this:
Dec BX
Js More
The Dec instruction sets the Sign Flag automatically, just as if a separate
compare had been performed.
Conditional Jump Instructions
Besides Je, Jne, and Js, there are a few other forms of conditional jump
instructions you should understand. Figure 12-6 lists all of the ones you
are likely to find useful.
Command Meaning
═══════ ══════════════════════════════════════
Je Jump if equal
Jne Jump if not equal
Ja Jump if above (unsigned basis)
Jna Jump if not above (unsigned basis)
Jb Jump if below (unsigned basis)
Jnb Jump if not below (unsigned basis)
Jg Jump if greater (signed basis)
Jng Jump if not greater (signed basis)
Jl Jump if less (signed basis)
Jnl Jump if not less (signed basis)
Jc Jump if Carry Flag is set
Jnc Jump if Carry Flag is clear
Js Jump if sign flag is set
Jns Jump if sign flag is not set
Jcxz Jump if CX is zero
Figure 12-6: The 8088 conditional jump instructions.
You should know that Je and Jne also have an alias command name: Jz and
Jnz. These stand for *Jump if Zero* and *Jump if Not Zero* respectively,
and they are identical to Je and Jne. In fact, though I didn't mention
this earlier, the Repe and Repne string repeat prefixes are sometimes
called Repz and Repnz.
Because Je and Jz cause MASM to generate the identical machine code
bytes, they may be used interchangeably. In some cases you may want to use
one instead of the other, depending on the logic in your program. For
example, after comparing two values you would probably use Je or Jne to
branch if they are equal or not equal. But after testing for a zero or
non-zero value using Or AX,AX you would probably use Jz or Jnz. This is
really just a matter of semantics, and either version can be used with the
same results.
Also, please understand that Jnb is not the same as Ja. Rather, the
case of being Not Below is the same as being Above Or Equal. In fact, MASM
recognizes Jae (Jump if Above or Equal) to mean the same thing as Jnb.
Likewise, Jbe (Jump if Below or Equal) is the same as Jna, Jge (Jump if
Greater or Equal) is the same as Jnl, and Jle (Jump if Less or Equal) is
identical to Jng. Again, which form of these instructions you use will
depend on how you are viewing the data and comparisons.
Note the special form of conditional jump, Jcxz. Jcxz stands for Jump
if CX is Zero, and it combines the effects of Cmp CX,0 and Je label into
a single fast instruction. Jcxz is also commonly used prior to a Loop
instruction. When you use Loop to perform an operation repeatedly, CX must
be assigned initially to the number of times the loop is to be executed.
But if CX is zero the loop will execute 65536 times! Thus, adding Jcxz
Exit avoids this undesirable behavior if zero was passed accidentally.
Finally, you must be aware that a conditional jump cannot be used to
branch to a label that is more than 128 bytes earlier, or 127 bytes farther
ahead in the code. A condition jump instruction is only two bytes, with
the first indicating the instruction and the other holding the branch
distance. If you need to jump to a label farther away than that you must
reverse the sense of the condition, and jump to a near label that skips
over another, unconditional jump:
Cmp AX,BX ;we want to jump to Label: if AX is greater
Jna NearLabel ;so jump to NearLabel if it's NOT greater
Jmp Label ;this goes to Label: which is farther away
NearLabel:
.
.
As used here, the unconditional Jmp instruction can branch to any location
within the current code segment. There is also a short form of Jmp, which
requires only two bytes of code instead of three. If you are jumping
backwards in the program and the address is within 128 bytes, MASM uses the
shorter form automatically. But if the jump is forward, you should specify
Short explicitly: Jmp Short Label. Some non-Microsoft assemblers do not
require you to specify Short; the newest MASM version 6.x also adjusts its
generated code to avoid the extra wasted byte.
DOS STRINGS
When string information is passed to a DOS routine, for example when giving
a file or directory name, the string must end with a CHR$(0). In DOS
terminology this is called an ASCIIZ string. (Do not confuse this with a
CHR$(26) Ctrl-Z which marks the end of a file.) Unlike BASIC, DOS does
not use string descriptors, so this is the only way DOS can tell when it
has reached the end. By the same token, when DOS returns a string to a
calling program, it marks the end with a trailing zero byte.
When passing a string to a DOS service from BASIC you must either
concatenate a CHR$(0) manually, or add extra code within the assembler
routine to copy the name into local storage and add a zero byte to the
copy. From BASIC you would therefore use something like this:
CALL Routine(FileName$ + CHR$(0))
BASIC FIXED-LENGTH STRINGS
Fixed-length strings and the string portion of a TYPE variable do not use
a string descriptor, which you might think would require a different
strategy to access them. But whenever a fixed-length string is used as an
argument to an assembler routine or BASIC subprogram, BASIC first copies
it into a temporary conventional string, and it is the temporary string
that is passed to the routine. When the routine returns, BASIC copies the
characters back into the original fixed-length string. Thus, any routine
written in assembly language that expects a descriptor will work correctly,
regardless of the type of string being sent.
Of course, this copying requires BASIC to generate many extra bytes of
assembler code for each call. If you do not want BASIC to create a
temporary string copy from one of a fixed-length, you must first define the
string as a TYPE like this:
TYPE Flen
S AS STRING * 20
END TYPE
DIM FString AS FLen
Though this appears to be the same as defining FString as a string with a
fixed length of 20, there is an important difference: declaring it as a
TYPE tells BASIC not to make a copy. That is, BASIC does not treat FString
as a string, as long as the ".S" portion that identifies it as a string is
not used. Here's an example based on the FLen TYPE that was defined above:
DIM FString AS FLen 'FString is a TYPE variable
FString.S = "This is a test" 'assign the string portion
CALL Routine(FString) 'call the routine without .S
Here, the address of the first character in the string is passed to the
routine, as opposed to the address of a temporary string descriptor. We
have told BASIC to call Routine, and pass it the entire FString TYPE but
without interpreting the .S string component. This next example does cause
BASIC to create a temporary copy:
CALL Routine(FString.X)
The short assembly language routine that follows expects the address of a
fixed-length string with a length of 20, as opposed to the address of a
string descriptor. The routine then copies the characters to the
upper-left corner of a color monitor.
Push BP ;access the stack as usual
Mov BP,SP
Mov SI,[BP+6] ;SI points to the first character
Mov DI,0 ;the first address in screen memory
Mov AX,0B800h ;color monitor segment when in text mode
Mov ES,AX ;move into ES through AX
Mov CX,20 ;prepare to copy 20 characters
Cld ;clear the direction flag to copy forward
More:
Movsb ;copy a byte to screen memory
Inc DI ;skip over the attribute byte
Loop More ;loop until done
Pop BP ;restore BP
Ret 2 ;return to BASIC
Recall that the color monitor segment value of 0B800h must be assigned to
ES through AX, because it is not legal to assign a segment register from
a constant. Also, notice the way that DI is cleared to zero. Although Mov
DI,0 indeed moves a zero into DI, this is not the most efficient way to
clear a register. Any time a numeric value is used in a program (0 in this
case), that much extra space is needed to store the actual value as part
of the instruction. A preferred method for clearing a register is with
the Xor instruction. That is, Xor DI,DI gives the same result as Mov DI,0
except it is one byte shorter and slightly faster.
When Xor is performed on any two values, only those bits that are
different are set to 1. But since the same register is used here for both
operands, all of the result bits will be cleared to 0. The code for using
Xor is decidedly less obvious, but you'll see Xor used this way very often
in assembly listings in magazines and books. Another, equally efficient
way to clear a register is to subtract it from itself using Sub AX,AX.
FAR STRINGS IN BASIC PDS
Accessing near strings in QuickBASIC and BASIC PDS is a relatively simple
task, because both the descriptor and the string data are known to be in
near DGROUP memory. But BASIC PDS also supports far strings, where the
data may be in a different segment. The composition of a far string
descriptor was shown in Chapter 2; however, you do not need to manipulate
these descriptors yourself directly.
BASIC PDS includes two routines--StringLength and StringAddress--that
do the work of locating far strings for you. Further, because Microsoft
could change the way far strings are organized in the future, it makes the
most sense to use the routines Microsoft supplies. If the layout of far
string descriptors changes, your program will still work as expected.
StringLength and StringAddress expect the address of the string
descriptor, and they return the string's length and segmented address
respectively. Note that while far string data may be in nearly any
segment, the descriptors themselves are always in DGROUP. Also note that
these routines are not very well-behaved. In particular, registers you may
be using are changed by the routines. To solve this problem and also to
let you get all of the information in a single call, I have written the
StringInfo routine. StringInfo is contained in the FAR$.ASM file on the
accompanying disk.
;from an idea originally by Jay Munro
.Model Medium, Basic
Extrn StringAddress:Proc ;these are part of PDS
Extrn StringLength:Proc
.Code
StringInfo Proc Uses SI DI BX ES
Pushf ;save the flags manually
Push ES ;save ES for later
Push SI ;pass incoming descriptor
Call StringAddress ;call the PDS routine
Pop ES ;restore ES for StringLength
Push AX ;save offset and segment
Push DX ; returned by StringAddress
Push SI ;pass incoming descriptor
Call StringLength ;get the length
Mov CX,AX ;copy the length to CX
Pop DX ;retrieve the saved Segment
Pop AX ;and the address
Popf ;restore the flags manually
Ret ;restore registers and return
StringInfo Endp
End
StringInfo is called with DS:SI pointing to the string descriptor, and it
returns the length in CX and the address of the string data in DX:AX.
Although StringInfo could be designed to return the segment in DS or ES,
it is safer to assign the segment registers yourself manually.
Notice the Uses clause--this tells MASM that the named registers must
be preserved, and generates additional code to push those registers upon
entry to the procedure, and pop them again upon exit.
Also notice the new Extrn directive at the beginning of the source file.
These tell the assembler that the stated routines are not in the current
source file. MASM then places the external name in the object file header,
with instructions to LINK to fill in the address portion of the Call. Data
must also be declared as external if it is not in the same source file as
the routine being assembled. When a data item is to be made available to
other modules, you must also have a corresponding Public statement in that
file for the same reason:
.Model Medium, Basic
.Data
Public MyData
MyData DW 12345
.
.
ACCESSING ARRAYS
================
As you have seen, a conventional variable is passed to an assembly language
subroutine by placing its address onto the stack. If the variable is a
string, then the address passed is that of its descriptor, and the string
data address is read from there. Accessing array elements is only slightly
more involved, because array elements are always stored in adjacent memory
locations. Let's look first at integer arrays.
When BASIC encounters the statement DIM X%(100) in your program, it
allocates a contiguous block of memory 202 bytes long. (Unless you first
used the statement OPTION BASE 1, dimensioning an array to 100 means 101
elements.) The first two bytes in this block hold the data for X%(0), the
next two bytes hold X%(1), and so forth. When you ask VARPTR to find
X%(0), the address it returns is the start of this block of memory.
The address of subsequent array elements may then be easily computed
from this base address. But with a dynamic array, the segment that holds
the array may not be the same as the segment where regular variables are
stored. Also, huge arrays that span more than 64K require extra care when
crossing a 64K segment boundary.
String arrays are structured in a similar fashion, in that each element
follows the previous one in memory. For each string array element that is
dimensioned, four bytes are set aside. These bytes comprise a table of
descriptors which contain the length and address words for each element in
the array. But the important point is that once you know where one element
or string descriptor is located, it is easy to find all of those that are
adjacent. Following is a QuickBASIC example that shows how to locate
Array$(15), based on the VARPTR address of Array$(0).
DIM Array$(100)
Array$(15) = "Find me"
Descriptor = VARPTR(Array$(0))
Descriptor = Descriptor + (4 * 15)
Length = PEEK(Descriptor) + 256 * PEEK(Descriptor + 1)
PRINT "Length ="; Length
Addr = PEEK(Descriptor + 2) + 256 * PEEK(Descriptor + 3)
PRINT "String = ";
FOR X = Addr TO Addr + Length - 1
PRINT CHR$(PEEK(X));
NEXT
DYNAMIC ARRAYS
Most of the routines shown so far manipulated variables that are located
in near memory. BASIC can store numeric, TYPE, and fixed-length string
arrays in far memory, and additional steps are needed to read from and
write to those arrays.
When an assembly language routine receives control after a call from
BASIC, it can access your regular variables because they are in the default
data segment. Most memory accesses assume the data is in the segment held
in the DS register. For example, the statement Mov [BX],AX assigns the
value in AX to the memory location identified by BX within the segment held
in DS. Likewise, Sub [DI+10],CX subtracts the value held in CX from the
memory address expressed as DI+10, where that address is again in the
default data segment.
It is also possible to specify a segment other than the current default.
One way is with a *segment override* command, like this:
Mov ES:[BX],AX
Here, the segment held in ES is used instead of DS. A segment override
adds only one byte of code, so it is quite efficient. If you plan to
access data in a different segment many times, you can optionally set DS
to that segment. However, it is mandatory that you reset DS to its
original value before returning to BASIC. You must also understand that
changing DS means you no longer have direct access to DGROUP anymore. In
that case you could use the stack segment as an override, since the stack
segment is always the same as the data segment in a BASIC program. The
next short example shows this in context.
Push DS ;save DS
Mov DS,FarSegment ;now DS points to your far data
. ;access that far data here
.
Mov AX,SS:[Variable] ;access Variable in DGROUP
. ;access more far data here
Pop DS ;restore DS before returning
When Microsoft introduced QuickBASIC version 2.0, one of the most exciting
new features it offered was support for dynamic numeric arrays. Unlike
QuickBASIC near strings, string arrays, and non-array variables, these
arrays are always located outside of BASIC's near 64K data segment. This
means that an assembler routine needs some way to know both the address and
the segment for an array element that is passed to it.
In general, routines you design that work on an entire array will be
written to expect a particular starting element. The routine can then
assume that all of the subsequent elements lie before or after it in
memory. Unfortunately, this does not always work unless you add extra
steps. If you call an assembly language routine passing one element of a
far-memory dynamic array like this:
CALL Routine(Array(1))
BASIC makes a copy of the array element into a temporary variable in near
memory, and then passes the address of that copy to the routine. Thus,
while the routine can still receive an array element's value, it has no way
to determine its true address. And without the address, there is no way
to get at the rest of the array.
Since being able to pass an entire array is obviously important, BASIC
supports two options to the CALL command--SEG and BYVAL. The SEG keyword
indicates that both the address and the segment are to be passed on the
stack, and it also tells BASIC not to make a copy of the array element.
SEG is used with an array element (or any variable, for that matter) like
this:
CALL Routine(SEG Array%(1))
You could also send the segment and address manually, like this:
CALL Routine(BYVAL VARSEG(Array%(1)), BYVAL VARPTR(Array%(1)))
In both cases, BASIC first pushes the segment where the element resides
onto the stack, followed by the element's address within that segment. By
pushing them in this order the routine can conveniently use either Lds
(Load DS) or Les (Load ES) to get both the segment and address in one
operation:
Les DI,[BP+6] ;if using manual stack addressing
or
Les BX,[StackArg] ;if using MASM's simplified directives
Les loads four bytes in one operation, placing the lower word at [BP+6]
into the named register (DI in the first example case), and the higher word
at [BP+8] into ES. Lds works the same, except the higher word is instead
moved into DS. Once the segment and address are loaded, you can access all
of the array elements:
Push DS ;save DS
Lds SI,[BP+6] ;now DS:SI points at first element
Mov [SI],AX ;assign Array%(1) from AX
Add SI,2 ;now SI points at the next element
Mov [SI],BX ;assign Array%(2) from BX
Pop DS ;restore DS
. ;continue
.
If Les were used instead of Lds, then an ES: override would be needed to
assign the elements. Although you must always preserve the contents of
DS regardless of the version of BASIC, some registers need to be saved only
when using BASIC PDS far strings. Other registers do not need to be saved
at all. Figure 12-7 shows which registers must be preserved based on the
version of BASIC.
QuickBASIC and BASIC PDS
PDS near strings far strings
═══════════════ ══════════
DS DS
SS SS
BP BP
SP SP
ES
SI
DI
Figure 12-7: The registers that must be preserved in an assembly language
subroutine.
Besides having to save and restore the registers shown in Figure 12-7, you
must also be sure that the Direction Flag is cleared to forward before
returning to BASIC. The Direction Flag affects the 8088 string operations,
and is by default set to forward. You can usually ignore the direction
flag unless you set it to backwards explicitly with the Std instruction.
In that case, you must use a corresponding Cld command.
Huge Arrays
A huge array is one that spans more than one 64K segment, and as you can
imagine, it requires extra steps to access all of the elements. That is,
the assembler routine must know which elements are in what segment, and
manually load those segments as needed. The following code fragment shows
how to walk through all of the elements in a huge integer array, and just
for the sake of the example adds each element to determine the sum of all
of them.
A simple setup example and call syntax for this routine is as follows:
REDIM Array&(1 TO 30000)
FOR X% = 1 TO 30000
Array&(X%) = X%
NEXT
CALL SumArray(SEG Array&(1), 30000, Sum&)
PRINT "Sum& ="; Sum&
And here's the code for the SumArray routine:
.Model Medium, Basic
.Code
SumArray Proc Uses SI, Array:DWord, NumEls:Word, Sum:Word
Push DS ;save DS so we can restore it later
Push SI ;PDS far strings require saving SI too
Xor AX,AX ;clear AX and DX which will accumulate
Mov DX,AX ; the total
Mov BX,NumEls ;get the address for NumElements%
Mov CX,[BX] ;read NumElements% before changing DS
Lds SI,Array ;load the address of the first element
Jcxz Exit ;exit if NumElements = 0
Do:
Add AX,[SI] ;add the value of the low word
Adc DX,[SI+2] ;and then add the high word
Add SI,4 ;point to the next array element
Or SI,SI ;are we beyond a 32k boundary?
Jns More ;no, continue
Sub SI,8000h ;yes, subtract 32k from the address
Mov BX,DS ;copy DS into BX
Add BX,800h ;adjust the segment to compensate
Mov DS,BX ;copy BX back into DS
More:
Loop Do ;loop until done
Exit:
Pop SI ;restore SI for BASIC
Pop DS ;restore DS and gain access to Sum&
Mov BX,Sum ;get the DGROUP address for Sum&
Mov [BX],AX ;assign the low word
Mov [BX+2],DX ;and then the high word
Ret ;return to BASIC
SumArray Endp
End
The segment bounds checking is handled by the six lines that start with
Or SI,SI. The idea is to see if the address is beyond 32767, subtract
32768 if it is, and then adjust the segment to compensate. The most direct
way would have been with Cmp SI,32767 and then Ja More, but Cmp used this
way generates three bytes of code, whereas Or creates only two bytes.
Since Or sets the Sign flag if the number is negative (above 32767), you
can use it to know when the address adjustment is needed.
Because it is not legal to add or subtract a segment register, DS is
first copied to BX, 800h is added to that, and the result is then copied
back to DS. 800h is used instead of 8000h (32768) because a new segment
begins every 16 bytes. [That is, adding 800h to a segment value is the
same as adding 8000h to the address.]
SumArray also introduces a new instruction: Adc means Add with Carry,
and it is used to add long integer values that by definition span two
words. When you add two registers--say, AX and BX--if the result exceeds
65535 only the remainder is saved. However, the Carry Flag is set to
indicate the overflow condition. Adc takes this into account, and adds
one extra to its result if the Carry Flag is set. Therefore, whenever two
long integers are added you'll use Add to combine the lower words, and Adc
for the high words. Similarly, subtracting long integers requires that you
use Sub to subtract the lower words and then Sbb (Subtract with Borrow) on
the upper words.
Although the details are hidden from you, when more than one parameter
is passed to an assembly language routine it is the last in the list that
is at [BP+6] on the stack. The previous argument is at [BP+8], and the one
before that is at [BP+10]. Because the stack grows downward as new items
are pushed onto it, each subsequent item is at a lower address.
Finally, in a real program this routine would probably be designed as
a function. Using a function avoids having to pass the Sum& parameter to
receive the returned value, and helps reduce the size of the program.
ASSEMBLER FUNCTIONS
===================
Designing a procedure as a function lets you return information to a
program, but without the need for an extra passed parameter. Functions are
also useful because BASIC performs any necessary data type conversion
automatically. For example, if you have written a function that returns
an integer value, you can freely assign the result to a single precision
variable.
You can also test the result of a function directly using IF, display
it directly with PRINT, or pass it as a parameter to another procedure.
Some typical examples are shown here:
SingleVar! = MyFunction%
IF YourFunction&(Argument%) > 1004 THEN ...
PRINT HisFunction$(Any$)
Beginning with QuickBASIC version 4.0, functions written in assembly
language may be added to a BASIC program. To have a function return an
integer value, simply place the value into the AX register before returning
to BASIC. If the function is to return a long integer, both DX and AX are
used. In that case, DX holds the higher word and AX holds the lower one.
STRING FUNCTIONS
String functions are only slightly more complicated to design. A string
function also uses AX as a return value, but in this case AX holds the
address of a string descriptor you have created. The complete short string
function that follows accepts an integer argument, and returns the string
"False" if the argument is zero or "True" if it is not.
;Syntax:
;DECLARE FUNCTION TrueFalse$(Argument%)
;Answer$ = TrueFalse$(Argument%)
.Model Medium, Basic
.Data
DescLen DW 0
DescAdr DW 0
True DB "True"
False DB "False"
.Code
TrueFalse Proc, Argument:Word
Mov DescLen,4 ;assume true
Mov DescAdr,Offset True
Mov BX,Argument ;get the address for Argument%
Cmp Word Ptr [BX],0 ;is it zero?
Jne Exit ;no, so we were right
Inc DescLen ;yes, return five characters
Mov DescAdr,Offset False ;and the address of "False"
Exit:
Mov AX,Offset DescLen ;show where the descriptor is
Ret ;return to BASIC
TrueFalse Endp
End
Although the function is declared using a dollar sign in the name, the
actual procedure omits that. [The dollar sign merely tells BASIC what type
of information will be returned. It is not part of the actual procedure
name.] TrueFalse begins by defining a string descriptor in the .Data
segment. It is also possible to store strings and other data in the code
segment and access it with a CS: segment override. However, data that is
returned as a function must be in DGROUP, and so must the descriptor.
The first two statements assign the descriptor to an output string
length of four characters, and the address of the message "True". Then,
the address of Argument is obtained from the stack, and its value is
compared to zero. If it is not zero, then the descriptor is already
correct and the function can proceed. Otherwise, the descriptor length is
incremented to reflect the correct length, and the address portion is
reassigned to show where the string "False" begins in memory. In either
case, the final steps are to load AX with the address of the descriptor,
and then return to BASIC.
MASM also lets you access data using simple arithmetic. For example,
the descriptor could have been defined as a single pair of words with one
name, and the second word could be accessed based on the address of the
first one like this:
.Data
Descriptor DW 0, 0
True DB "True"
False DB "False"
.Code
.
.
Inc Descriptor
Mov Descriptor+2,Offset False
.
.
Far String Functions
Far string functions require more work to write than near string functions,
because of the added overhead needed to support far strings. Fortunately,
BASIC includes routines that simplify the task for you. Actually, the
routines to create and assign strings have always been included; it's just
that Microsoft never documented how to do it before BASIC 7.0. Later in
this chapter I'll show code to create strings that works with all versions
of BASIC 4.0 or later.
The StringAssign routine expects six arguments on the stack, for the
segment, address, and length of both the source and destination strings.
StringAssign can assign from or to any combination of fixed- and variable-
length strings. If the length argument for either string is zero, then
StringAssign knows that the address is that of a descriptor. Otherwise,
the address is of the data in a fixed-length string.
Because of the added overhead of obtaining values and pushing them on
the stack, I have created a short wrapper program that does this for you.
MakeString accepts the same arguments as StringAssign, but they are passed
using registers rather than on the stack. Of course, calling one routine
that in turn calls another takes additional time. But the savings in code
size when MakeString is called repeatedly will overshadow the very slight
additional delay.
MakeString is called with DX:AX holding the segmented address of the
source string, and CX holding its fixed length. If the source is a
conventional string, CX is set to zero to indicate that. The destination
address is identified with DS:DI, using BX to hold the length. Again, BX
holds zero if the destination is not a fixed-length string.
;from an idea originally by Jay Munro
.Model Medium, Basic
Extrn STRINGASSIGN:Proc
.Code
MakeString Proc Uses DS
Push DX ;push the segment of the source string
Push AX ;push the address of the source string
Push CX ;push the string length
Push DS ;push the segment of the destination
Push DI ;push the address of the destination
Push BX ;push the destination length
Call STRINGASSIGN ;call BASIC to assign the string
Ret
MakeString Endp
End
Now, with the assistance of MakeString, TrueFalse$ can be easily modified
to work with BASIC 7 far strings:
.Model Medium, Basic
Extrn MakeString:Proc ;this is in FAR$.ASM
.Data
Descriptor DW 0, 0 ;the output string descriptor
True DB "True"
False DB "False"
.Code
TrueFalse Proc Uses ES DS SI DI, Argument:Word
Mov CX,4 ;assume true
Mov AX,Offset True
Mov BX,Argument ;get the address for Argument%
Cmp Word Ptr [BX],0 ;is it zero?
Jne @F ;no, so we were right
Inc CX ;yes, assign five characters
Mov AX,Offset False ;and use the address of "False"
@@:
Mov DX,DS ;assign the segment and address
Mov DI,Offset Descriptor ; of the destination descriptor
Xor BX,BX ;assign to a descriptor
Call MakeString ;let MakeString do the work
Mov AX,DI ;AX = address of output descriptor
Ret ;return to BASIC
TrueFalse Endp
End
Notice the introduction of the new at-symbol (@) assembler directive. The
at-symbol and double at-symbol label are quite useful, because they let you
avoid having to create unique label names each time you specify the target
of a jump. As with BASIC, creating many different label names is a
nuisance, and also impinges on the assembler's working memory. When a
label is defined using @@: as a name, you can jump forward to it using @F
or backwards using @B. Multiple @@: labels may be used in the same
program, and @F and @B always branch to the nearest one in the stated
direction.
FLOATING POINT FUNCTIONS
Single and double precision functions are handled in yet another manner.
Although a single precision value could be returned in the DX:AX register
combination, a double precision result would need four registers, which is
impractical. Further, a floating point number is most useful to BASIC if
it is stored in a memory location, rather than in registers.
When BASIC invokes a floating point function it adds an extra, dummy
parameter to the end of the list of arguments you pass. If no parameters
are being used, it creates one. This parameter is the address into which
your routine is to place the outgoing result. Because of this added
parameter, it is essential that you account for it when returning to BASIC.
Thus, a function without arguments must use Ret 2, a function with one
argument needs Ret 4, and so forth. Since we're using MASM's simplified
directives, all that is needed is to create an extra parameter name.
The short double precision function that follows squares a double
precision number much faster than using Value# ^ 2, and also shows how to
perform simple floating point math using assembly language. You will
declare and invoke Square like this:
DECLARE FUNCTION Square#(Variable#)
Result = Square#(Variable#)
;SQUARE.ASM, squares a double precision number
;
;WARNING: This file must be assembled using /e (emulator).
.Model Medium, Basic
.Code
.8087 ;allow 8087 instructions
Square Proc, InValue:Word, OutValue:Word
Mov BX,InValue ;get the address for InValue
FLd QWord Ptr [BX] ;load InValue onto the 8087 stack
FMul QWord Ptr [BX] ;multiply InValue by itself
Mov BX,OutValue ;get the address for OutValue
FStp QWord Ptr [BX] ;store the result there
FWait ;wait for the 8087 to finish
Mov AX,BX ;return DX:AX holding the full
Mov DX,DS ; address of the output value
Ret ;return to BASIC
Square Endp
End
This Square function illustrates several important points. The first is
the use of MASM's /e switch, which lets an assembly language routine share
BASIC's floating point emulator. When a BASIC program begins, it looks to
see if an 8087 coprocessor is installed in the host PC. If so, it uses one
set of library routines; otherwise it uses another.
The library routines that use an 8087 simply modify the caller's code
to change the floating point interrupts that BASIC generates into actual
8087 instructions. It then returns to the instruction it just created and
executes it. Although this adds to the time needed to perform a floating
point operation, the code is patched only once. Thus, statements within
a FOR or DO loop operate very quickly after the first iteration. This is
very much like the method used by the BRUN library described in Chapter 1.
When no coprocessor is detected, the floating point interrupts that
BASIC generates are used to invoke routines in BASIC's floating point
software emulator. As its name implies, an emulator imitates the behavior
of a coprocessor using assembly language commands. A coprocessor can
perform a variety of floating point operations, including addition,
multiplication, and rounding, as well as some transcendental functions such
as logarithms and arctangents.
When you use the /e switch, MASM adds extra information to the object
file header that tells LINK where to patch your 8087 instructions. LINK
can then change your code to the equivalent floating point interrupts,
similar to the way BASIC patches its own code to change the interrupts to
8087 instructions. Therefore, when you write floating point code that will
be called from BASIC, your routine can tie into BASIC's emulator, and use
it automatically if no coprocessor is installed.
Also, notice the .8087 directive which tells MASM not to issue an error
message when it sees those instructions. Other, similar directives are
.80287 and .80387, and also .80286 and .80386. These directives inform
MASM that you are intentionally using advanced commands that require these
processors, and have not made a typing error.
The actual body of the Square function is fairly simple. First, the
address of the incoming value is retrieved from the system stack, and then
the data at that address is loaded onto the coprocessor's stack using the
FLd (Floating point Load) instruction. Since this is a double precision
value, QWord Ptr (Quad Word Pointer) is needed to indicate the size of the
data. Had the incoming value been single precision, DWord Ptr (Double
Word Pointer) would be used instead. One important feature of an 8087 or
software emulator is that a number may be converted from one numeric format
to another simply by loading it as one data type, and then saving it as
another.
The next instruction, FMul (Floating point Multiply), multiplies the
value currently on the 8087 stack by the same address. Since the original
value is still present, there's no need to make a new copy. Next, the
destination address is placed into BX, and the result now on the 8087 stack
is stored there. The trailing letter p in the FStp instruction specifies
that the value loaded earlier is to be popped from the coprocessor stack.
A complete discussion of 8087 instructions and how the coprocessor stack
operates goes beyond what I can hope to cover here. When in doubt about
what instruction is needed, I suggest that you code a similar sample in
BASIC, and then examine the code BASIC generates using CodeView. There are
also several books that focus on writing floating point instructions in
assembly language.
The last 8087 instruction is FWait, and it tells the 8088 to wait until
the coprocessor has finished, before continuing. Because an 8087 is a true
coprocessor, it operates independently of the main 8088 CPU. Once a value
is loaded and the 8087 is instructed to perform an operation, the 8087
returns immediately to the program that issued the instruction and
continues to process the numbers in the background. If Square exited
immediately and BASIC read the returned value, there's a good chance that
the 8087 did not finish and the value has not yet been stored! In that
case, whatever happened to be in memory at that time would be the value
that BASIC uses, which is obviously incorrect.
Experienced 8087 programers know how long the various coprocessor
instructions take to complete, and with careful planning the number of
FWait commands can be kept to a minimum. However, the code that BASIC
generates always finishes with an FWait. Of course, there is no need to
wait when the emulator is in use. In fact, an FWait is patched by BASIC
to do nothing (Mov AX,AX), rather than waste time invoking an empty
interrupt handler repeatedly.
As shown, Square can be added to a Quick Library for use with either
QuickBASIC or BASIC PDS. Unfortunately, the information link needs to
patch 8087 instructions is available only with BASIC PDS. Therefore, the
following file is included in the libraries on the accompanying disk, to
supply the external data that LINK requires.
;FIXUPS.ASM, deciphered by Paul Passarelli
FIARQQ Equ 0FE32h
FJARQQ Equ 04000h
FICRQQ Equ 00E32h
FJCRQQ Equ 0C000h
FIDRQQ Equ 05C32h
FIERQQ Equ 01632h
FISRQQ Equ 00632h
FJSRQQ Equ 08000h
FIWRQQ Equ 0A23Dh
Public FIARQQ
Public FJARQQ
Public FICRQQ
Public FJCRQQ
Public FIDRQQ
Public FIERQQ
Public FISRQQ
Public FJSRQQ
Public FIWRQQ
End
These values are added to the floating point instruction bytes during the
linking process, and the addition converts those statements into equivalent
BASIC floating point interrupt commands. For example, the 8087 statement
Fld DWord Ptr [1234h] is represented in memory as the following series of
Hexadecimal bytes:
9B D9 06 34 12
After LINK adds the value FIDRQQ (5C32h) to the first two bytes of this
command the result is:
CD 35 06 34 12
And when disassembled back to assembler mnemonics, the CD35h displays as
Int 35h. The three bytes that follow are always left unchanged, and they
specify the type of operation--DWord Ptr on a memory location--and the
address of that location.
Floating Point Comparisons
At the core of any sorting or searching routine is an appropriate
comparison function. Previous chapters showed how to compare string data,
and as you can imagine comparing floating point values is much more
complex. But now that you know how to tap into BASIC's floating point
routines it is almost trivial to effect a floating point comparison. The
routines that follow let you compare either single- or double precision
values, by passing them as arguments.
;COMPAREFP.ASM, compares floating point values
;WARNING: This file must be assembled using /e (emulator)
.Model Medium, Basic
Extrn B$FCMP:Proc ;BASIC's FP compare routine
.8087 ;allow coprocessor instructions
.Code
CompareSP Proc, Var1:Word, Var2:Word
Mov BX,Var2 ;get the address of Var1
Fld DWord Ptr [BX] ;load it onto the 8087 stack
Mov BX,Var1 ;same for Var2
Fld DWord Ptr [BX]
FWait ;wait until the 8087 says it's okay
Call B$FCMP ;compare the values, (and pop both)
Mov AX,0 ;assume they're the same
Je Exit ;we were right
Mov AL,1 ;assume Var1 is greater
Ja Exit ;we were right
Dec AX ;Var1 must be less than Var2
Dec AX ;decrement AX to -1
Exit:
Ret ;return to BASIC
CompareSP Endp
CompareDP Proc, Var1:Word, Var2:Word
Mov BX,Var2 ;as above
Fld QWord Ptr [BX]
Mov BX,Var1
Fld QWord Ptr [BX]
FWait
Call B$FCMP
Mov AX,0
Je Exit
Mov AL,1
Ja Exit
Dec AX
Dec AX
Exit:
Ret
CompareDP Endp
End
Like the Compare3 function shown in Chapter 8, CompareSP and CompareDP are
integer functions that return -1, 0, or 1 to indicate if the first value
is less than, equal to, or greater than the second. Therefore, to use
these from BASIC you would invoke them like this:
IF CompareSP%(Value1!, Value2!) = -1 THEN
'the first value is smaller than the second
END IF
And to test if the first is equal to or greater than the second you would
instead do this:
IF CompareSP%(Value1!, Value2!) >= 0 THEN
'the first value is equal or greater
END IF
You can also use these functions from assembly language. But if you do
this, I suggest a simple modification. A comparison routine meant to be
called from another assembler routine would not generally return the result
in the registers. Rather, it would leave the flags set appropriately for
a subsequent Ja or Jne branch.
Fortunately, BASIC's B$FCMP routine already does this. Therefore, you
will make a copy of the COMPAREF.ASM source file, and delete the six lines
between the call to B$FCMP and the Ret instruction. You can also remove
the Exit: label if you like, although its presence causes no harm. Of
course, the code itself is so simple that the best solution may be to
simply duplicate the same instructions inline in your routine.
EXPLOITING MASM'S FEATURES
==========================
Each example I have shown so far introduced another useful MASM feature.
For example, you learned how MASM lets you establish data memory with an
initial value, so you don't have to assign it explicitly. But there are
several other features you should know about as well. One is conditional
assembly.
CONDITIONAL ASSEMBLY
With conditional assembly you can specify that only certain portions of a
file are to be assembled. This makes it easier to maintain two different
versions of a routine, for example one for near strings and one for far
strings. If you had to create two separate copies of the source file, any
improvements or bug fixes that you add would have to be done twice.
There are two ways that a section of code can be optionally included or
excluded. One is to define a constant at the beginning of the source file,
and then test that constant using a form of IF and ELSE test. Like BASIC,
MASM lets you define constant values using meaningful names. The problem
with this method--albeit a minor one--is that you must alter the code prior
to assembling each version. The example that follows shows how this kind
of conditional assembly is employed.
MyConst = 1
.
.
IF MyConst
;do whatever you want here
ELSE ;the ELSE is optional
;do whatever else you want here
ENDIF
.
.
The idea is that if you want the code that follows the IF test to be
assembled, you would use a non-zero value for MyConst. If you wanted to
create an alternate version using the code within the optional ELSE block,
you would change the value to be zero.
You can also use IFE (If Equal to zero) to test if a constant is zero.
And this brings up another interesting MASM feature. There are actually
two types of constants you can define. The constant MyConst shown above
is called a *redefinable* constant, because you can actually change its
value during the course of a program. The other type of constant is
defined using the Equ (Equate) directive, and may not be changed:
YourConst Equ 100
Redefinable constants are often used in repeating macros, and macros are
discussed later in this section.
The other way to tell MASM that it is to assemble just a portion of the
file is with IFDEF. IFDEF (If Defined) tests if a constant has been
defined at all, as apposed to comparing for a specific value. The value
of this approach is that you can define a constant on the MASM command line
when you run it. The first example below tells MASM to assemble the code
within the IFDEF block, and the second tells it to not to.
C:\ASM\> masm program /def myconst ;
C:\ASM\> masm program ;
Here's the portion of the routine that is being assembled conditionally:
IFDEF MyConst
;do something optional here
ENDIF
Likewise, IFNDEF (If Not Defined) tests if a constant has not been defined
when reversing the logic is more sensible to you. MASM includes a great
number of such conditional tests, and only by reading that section of the
MASM manual will you become familiar with those that are the most useful.
COMMENT BLOCKS
Another useful MASM feature that I personally would love to see added to
BASIC is multi-line comment blocks. The Comment command accepts any single
character you choose as a delimiter, and considers everything thereafter
to be comments until the same character is encountered. Many programmers
use a vertical bar, because it is not a common character:
Comment |
This program is intended to blah blah blah, and it works
by loading AX with blah blah blah.
|
Besides avoiding the need to place an explicit semicolon on each comment
line, this also makes it easy to remark out large sections of code while
you are debugging a routine.
QUOTED STRINGS
Yet another useful feature is MASM's willingness to use either single or
double quotes to indicate ASCII text and individual characters. In BASIC,
if you want to specify a double quote you must use CHR$(34)--it simply is
not legal to use """, where the quote in the middle is the character being
defined. [With the introduction of VB/DOS triple quotes may now be used
for this purpose.] If you need to define a double quote simply surround
it with apostrophes like this:
SomeData DB '"'
Mov AH, '"'
Or you can place a single quote within double quotes like this:
Add DL, "'"
MASM can use either convention as needed, which is a feature I personally
like a lot.
LENGTH AND ADDRESS SELF-CALCULATION
Whenever MASM sees the dollar sign ($) operator it interprets that to mean
*here*, or the current address. This can be used both for data and code,
though it is more common with data as the example below illustrates.
.Data
Descriptor DW MsgLen, Address
Message DB "This is a message."
Address = Offset Message
MsgLen = $ - Address
The expression $ - Address tells the assembler to take the current data
address, and subtract from that the address where Message begins. This is
a very powerful concept because it frees the programmer from many tedious
calculations. In particular, if the string contents are changed at a later
time, the new length is recalculated by MASM automatically.
DEFINING DATA STRUCTURES
To assist you in manipulating data structures, MASM offers the Struc
directive. This is identical to BASIC's TYPE statement, whereby you define
the organization of a collection of related data items. The example below
shows how to define a custom data structure using BASIC, followed by an
equivalent MASM Struc definition.
BASIC:
TYPE MyType
LastName AS STRING * 15
FirstName AS STRING * 12
ZipCode AS STRING * 5
RecordPtr AS LONG
END TYPE
DIM MyVar AS MyType
MASM:
Struc MyStruc
LastName DB 15 Dup (?)
FirstName DB 12 Dup (?)
ZipCode DB 5 Dup (?)
RecordPtr DD ?
MyStruc Ends
MyVar DB Size MyStruc Dup (?)
Like BASIC, defining a structure merely establishes the number and type
of data items that will be stored; memory is not actually set aside until
you do that manually. In BASIC, you must use DIM to establish the memory
that will hold the TYPE variable. In assembly language you instead use DB
in conjunction with the Size directive, to set aside the appropriate number
of bytes.
Each component of the Structure is defined using an identifying name and
a corresponding data type. Then, whenever a structure member is referenced
in your assembler routine, MASM replaces it with a number that shows how
far into the structure that member is located. MASM uses the same syntax
as BASIC, with a period between the data name and the structure identifier.
Here are a few examples:
Mov AL,[BX+MyVar.LastName] ;same as Mov AL,[BX+15]
Les DI,[MyVar.RecordPtr] ;loads ES:DI from RecordPtr
MINIMIZING DGROUP USAGE
=======================
In many cases you will store the variables your routines need in DGROUP
using the .Data directive. As with static subprograms and functions in
BASIC, this data will not change between subroutine calls. But this also
means that these variables are combined into the same 64k segment that is
shared with BASIC. When there are many variables or many different
routines each with their own variables, this can significantly reduce the
amount of near memory available to BASIC. There are two effective
solutions to this problem.
LOCAL VARIABLES
One way to reduce the DGROUP impact of many variables is to place some of
them onto the system stack. MASM lets you do this automatically with its
Local directive, or you can do it manually by subtracting the requisite
number of bytes from SP. Of course, there is only so much room on the
stack, so this approach is most useful when there are many routines and
each has less than 1K or so of data. Stack variables are also useful when
programming for OS/2 or Windows. These operating systems require that all
of your procedures be reentrant so static variables cannot be used.
The example below creates room for fifty words of local storage on the
stack, and then clears the variables to zero.
Routine Proc Uses ES DI, Param1:Word, Param2:Word
Sub SP,100 ;50 words = 100 bytes
Push SS ;assign ES from SS
Pop ES
Mov DI,SP ;point DI to the start of storage
Xor AX,AX ;fill with zeros
Mov CX,50 ;clear fifty words
Rep Stosw ;store AX CX times at ES:[DI]
. ;the routine continues
.
Add SP,100 ;restore SP to what it had been
Ret ;return to BASIC
Routine Endp
MASM can also do this automatically for you using Local like this:
Routine Proc Uses ES DI, Param1:Word, Param2:Word
Local Buffer [100]:Byte
Lea DI,Buffer ;clear the stack variables here
. ;the routine continues
.
Ret ;return to BASIC
Routine Endp
As you can see, Local lets you refer to the start of the local stack data
area by name. Notice how Lea is required here, because the address of
Buffer is expressed as an offset from BP. That is, MASM translates the
Lea instruction to Lea DI,[BP-100]. You cannot use Mov DI,Offset Buffer
because Buffer's address (which is based on the current setting of the
stack pointer) is not known when the routine is assembled or linked.
In this case only one local block is defined, so you could also use Mov
DI,SP to set DI to point to the start of the data. It is not strictly
necessary to clear the stack space before using it, but it is important to
understand that whatever junk happened to be in memory at that time will
still be there after using Local.
It is also important to be aware of a number of bugs with the Local
directive. I have found that limiting the use of Local to a single set of
data as shown here is safe with all MASM versions through 5.1. Using
multiple Local directives defined with data structures can result in the
wrong part of the stack being written to when a structure member is
accessed by name.
STORING DATA IN THE CODE SEGMENT
Another time-honored technique for conserving DGROUP memory is to place
selected variables into the code segment. In most cases storing data for
a routine in the code segment will make your programs slightly larger and
slower, because of the need for an added CS: segment override. But when
large amounts of data must be accommodated, this can be very valuable
indeed. One advantage to using the code segment is that you can establish
initial values for the data, which is not possible when using the stack.
As an example of this technique, I have written a string function called
Message$ that stores a series of messages in the code segment. In this
case only a single CS: segment override is needed, so the impact of using
the code segment for data is insignificant. Message$ is designed to be
declared and invoked as follows:
DECLARE FUNCTION Message$(BYVAL MsgNumber%)
Result$ = Message$(AnyInt%)
Message$ is table driven, which makes it simple to modify the routine to
change or add messages without having to make any changes to the function's
structure. As shown here, Message$ is designed to return the name of a
weekday, given a value between one and seven. You can easily modify it to
return other strings of nearly any length.
.Model Medium, Basic
Extrn B$ASSN:Proc ;BASIC's assignment routine
.Data
Descriptor DD 0 ;the output string descriptor
Null$ DD 0 ;use this to return a null
; (needed for BASIC PDS only,
.Code ; but okay with QuickBASIC)
Message Proc Uses SI, MsgNumber:Word
Mov SI,Offset Messages ;point to start of messages
Xor AX,AX ;assume an invalid value
Mov CX,MsgNumber ;load the message number
Cmp CX,NumMsg ;does this message exist?
Ja Null ;no, return a null string
Jcxz Null ;ditto if they pass a zero
Do: ;walk through the messages
Lods Word Ptr CS:0 ;load and skip over this message's length
Dec CX ;show that we read another
Jz Done ;this is the one we want
Add SI,AX ;skip over the message text
Jmp Short Do ;continue until we're there
Done:
Or AX,AX ;are we returning a null?
Jz Null ;yes, handle that differently
Push CS ;no, pass the source segment
Done2:
Push SI ;and the source address
Push AX ;and the source length
Push DS ;pass the destination segment
Mov AX,Offset Descriptor ;and the destination address
Push AX
Xor AX,AX ;0 means assign a descriptor
Push AX ;pass that as well
Call B$ASSN ;let B$ASSN do the dirty work
Mov AX,Offset Descriptor ;show where the output is
Ret ;return to BASIC
Null:
Push DS ;pass the address of Null$
Mov SI,Offset Null$
Jmp Short Done2
Message Endp
;----- DefMsg macro that defines messages
DefMsg Macro Message
LOCAL MsgStart, MsgEnd ;;local address labels
NumMsg = NumMsg + 1 ;;show we made another one
IFB <Message> ;;if no text is defined
DW 0 ;;just create an empty zero
ELSE ;;else create the message
DW MsgEnd - MsgStart ;;first write the length
MsgStart: ;;identify the starting address
DB Message ;;define the message text
MsgEnd Label Byte ;;this marks the end
ENDIF
Endm
Messages Label Byte ;the messages begin here
NumMsg = 0 ;tracks number of messages
;DO NOT MOVE this constant
DefMsg "Sunday"
DefMsg "Monday"
DefMsg "Tuesday"
DefMsg "Wednesday"
DefMsg "Thursday"
DefMsg "Friday"
DefMsg "Saturday"
End
After declaring BASIC's B$ASSN routine as being external, Message$ defines
two string descriptors in the Data segment. The first is used for the
function output when returning a normal message, and the second is used
only when returning a null string. In truth, the need for a separate
output descriptor and the slight added steps to detect the special case of
a null output string is needed only with BASIC PDS far strings. And this
brings up an important point.
It is impossible to write one assembly language subroutine that can work
with both QuickBASIC and BASIC PDS far strings using the normal, documented
methods. To create a string function for use with QuickBASIC and PDS near
strings, you define and fill in a string descriptor in DGROUP, and assign
its address in AX before returning to BASIC. And to return a far string
as a function for PDS requires calling the internal STRINGASSIGN routine
that Microsoft provides with PDS. STRINGASSIGN works with both near and
far strings in PDS, but is not available in QuickBASIC.
The trick is to use the *undocumented* name B$ASSN, which is really the
same thing as STRINGASSIGN. The big difference, though, is that B$ASSN is
available in all versions of BASIC 4.0 and later. When near strings are
used the B$ASSN routine is extracted from the near strings library. When
linking with far strings a different version is used, extracted by LINK
from the far strings library. This is a powerful concept to be sure, and
one we will use again for other examples later on in this chapter.
Message$ begins by loading SI with the starting address of a table of
messages. These messages are located at the end of the source file in the
code segment, and each is preceded with the length of the text. Although
it may not be obvious from looking at the source listing, the message data
is actually structured like this:
DW 6
DB "Sunday"
DW 6
DB "Monday"
.
.
Next, AX is cleared to zero just in case the incoming string number is
illegal. Later in the program AX holds the length of the output string;
clearing it here simply makes the program's logic more direct.
CX is then loaded with the message number the caller asked for. If CX
is either higher than the available number of messages or zero, the program
jumps to the code that returns a null string. Otherwise, a small loop is
entered that walks through each message, decrementing CX as it goes. When
CX reaches zero, SI is pointing at the correct message and AX is holding
its length. Otherwise, the current length is added to SI, thus skipping
over that data.
Notice the unusual form of the Lodsw statement, to allow it to work with
a CS: override. MASM has a number of quirks that are less than intuitive,
and this is but one of them. Normally you would use either Lodsb or Lodsw,
to indicate loading either a byte into AL or a word into AX. But when you
use a segment override MASM requires omitting the "b" or "w" Lods suffix,
and you must state Byte Ptr or Word Ptr explicitly. Then, a dummy argument
must be placed after the override colon.
MASM MACROS
The last new feature this listing introduces is the use of macros. The
most basic use of MASM macros is to define a block of code once, and then
repeat it multiple times with a single statement. This is not unlike
keyboard macro programs such as Borland's SuperKey, that let you assign a
string of text to a single key. For example, you could press Alt-S and
SuperKey will type "Very truly yours", five Enter keys, and then your name.
MASM macros also offer many other interesting and useful capabilities,
including the ability to accept arguments. [I should mention that the main
point of the DefMsg macro is to make this function easy to modify, so you
can create other, similar string functions from this same routine.] Before
attempting to explain the DefMsg (Define Message) macro I designed for use
with Message$, let's consider some macro basics.
Say, for example, you find that a particular routine needs to push the
same five registers many times during the course of a procedure. To
simplify this task you could define a macro--perhaps named PushRegs--that
performs the code sequence for you. Such a macro definition would look
like this:
PushRegs Macro
Push AX
Push BX
Push SI
Push DS
Push ES
PushRegs Endm
Now, each time you want to execute this series of instructions you would
simply use the command PushRegs. Please understand that a macro is not the
same as a called subroutine. The assembler still places each Push command
in sequence into your source code each time the macro is invoked. But a
simple macro like this can reduce the amount of typing you must do, and
minimize errors such as pushing registers in the wrong order. And in some
cases Macros also make your code easier to read.
As I mentioned, a MASM macro can accept arguments, and it can even be
designed to accept a varying number of them. If you need to push three
registers but which ones may change, you would define PushRegs like this:
PushRegs Macro Reg1, Reg2, Reg3
Push Reg1
Push Reg2
Push Reg3
Endm
Then to push AX, SI, and DI you would invoke PushRegs as follows:
PushRegs AX, SI, DI
Of course, a corresponding PopRegs macro would be defined similarly. Once
a macro has been defined you can pass any legal argument to it. For
example, you could also use this:
PushRegs AX, Word Ptr [BP-20], IntVar
Here, you are pushing AX, the word 20 bytes below where BP points to on
the stack, and the integer variable named IntVar.
A useful enhancement to this macro would let you pass it a varying
number of parameters. The PushM macro that follows accepts any number of
arguments (up to eight), and pushes each in sequence.
PushM Macro A,B,C,D,E,F,G,H ;;add more place-holders to suit
IRP CurArg, <A,B,C,D,E,F,G,H> ;;repeat for each argument
IFNB <CurArg> ;;if this arg is not blank
Push CurArg ;;push it
ENDIF
Endm ;;end of repeat block
Endm ;;end of this macro
From this you can create a complementary PopM macro by changing the name,
and also changing the Push instruction to Pop.
The IRP command works much like a FOR/NEXT loop in BASIC, and tells MASM
to repeat the following statements for each argument that was given. IFNB
(If Not Blank) then tests each argument to see if it was in fact present
in the incoming list of parameters. In this case, CurArg assumes the name
of the argument, and the Push instruction is expanded to specify that name.
There is no disputing that the syntax of a MASM macro is confusing at
best. Having to enclose some arguments in angle brackets but not others
requires frequent visits to the MASM manual. Further, a MASM macro is
virtually impossible to debug. If you write a macro incorrectly or create
a syntax error, MASM reports an error at the line where the macro was
invoked, rather than at the line containing the error in the macro. It is
not uncommon to receive a number of errors all pointing to the same source
line, with no indication whatsoever where the error really is.
Now consider how the DefMsg macro operates. DefMsg begins by defining
a single incoming parameter named Message. Two local labels--MsgStart and
MsgEnd--are defined, and these are needed so MASM can calculate the length
of the messages. Although labels within a macro do not have to be declared
as local, you would get an error if the macro were used more than once.
Like BASIC, the assembler requires that each label have a unique name. By
using local labels MASM generates a new, unique internal name for each
macro invocation, instead of the actual label name given.
The next statement increments a MASM variable named NumMsg. To avoid
an error caused by calling Message$ with an invalid message number, it
compares the number you pass to the number of messages that are defined.
This test occurs in the fourth line of the procedure, at the Cmp CX,NumMsg
statement. NumMsg is a constant, except it may be redefined within the
routine. (When a constant is assigned using the word Equate, its value
may not be changed by either your source code or by a macro.) But when a
variable is defined using an equals sign (=), MASM allows it to be altered
as it assembles your program. Understand that the resulting number is
added to your program as a constant. However, its value can be changed
during the course of assembly. Therefore, each time DefMsg is invoked, it
increments NumMsg. MASM places the final value into the Cmp instruction,
as if you had defined it using a fixed known value.
The IFB (If Blank) test checks to see if DefMsg was given a parameter
when it was invoked. In most cases you will probably want to define a
series of consecutive messages. As it is used here, seven different day
names are returned in sequence. But there may be times when you want to
leave a particular message number blank. For example, you could create a
series of messages that correspond to BASIC's error numbers. BASIC file
error numbers range from 50 through 76, but there are no messages numbers
60, 65, or 66. You could therefore leave those blank, and invoke a
modified copy of Message$ like this:
CALL DOSMessage$(51 - ERR)
When DefMsg is used with no argument, it merely creates a zero word at
that point in the code segment. Otherwise, the length of the message is
stored, followed by the message text. The statement DW MsgEnd - MsgStart
is replaced with the difference between the addresses, which MASM
calculates for you. This is similar to the earlier example that showed how
a dollar sign ($) can simplify defining strings that may change.
The last macro I will describe here is Rept, which means "Repeat the
following statements a given number of times". In the simplest sense, Rept
could be used to generate a series of the same instructions:
Rept 100
Xor AX,AX
Push AX
Call SomeProc
Endm
A Rept macro is not invoked by name; rather, it is added inline to a
program (or included within a macro that is called by name). In most cases
you would use a coding loop to repeat a block of code, since a Rept macro
actually generates the same code repeatedly in the program. But there are
situations where timing is very critical, and a loop is always somewhat
slower than a sequence of inline instructions.
Another good use for Rept is in conjunction with redefinable equates,
such as this example which defines the letters of the alphabet:
Alphabet:
Char = 0
Rept 26 ;;do this 26 times
DB "A" + Char ;;define ASC("A") + Char
Char = Char + 1 ;;increment Char
Endm
Although the MASM manual states that you must use double semicolons for
remarks within a macro as shown here, I have used a single semicolon
without problems.
There are other macro commands and features I will not describe here,
because I have not found them to be particularly useful. However, macros
can be recursive, multiple macros may be nested, and even redefined on the
fly. I urge you to refer to the documentation that Microsoft provides for
more information on those advanced features.
SEGMENT NAMING
==============
Aside from the short PrtSc example shown earlier in this chapter, we have
relied upon MASM's simplified segmentation directives to spare us from the
nuisance of defining and naming segments. Indeed, when writing routines
that will be added to BASIC it is rarely necessary to do this manually, so
why bother?
One place where naming segments explicitly is useful is when you have
many internal procedures that are never called from BASIC directly. If,
for clarity and organization reasons, you decide to store those routines
in different files, you still may want to access the routines using near
calls. Since a near call is two bytes shorter than a far call and also
operates slightly faster, this can make a difference when there are many
Call commands within the routines.
As LINK pulls all of the various pieces of your program together from
separate object and library files, it reads the segment names and combines
those with the same name. Thus, a routine in one source file can call a
routine in a different file, and LINK will place both routines into the
same segment if they use the same segment name. This is of course needed
to ensure that the called routine is reachable by the caller (within 64K).
All of the standard segment names that Microsoft recommends are listed
in the MASM manual, along with instructions for creating your own names.
Therefore, I won't belabor that here.
ACCESSING BASIC INTERNALS
=========================
In preceding sections you learned that it is possible--even desireable--
to call BASIC's internally routines directly. Besides those that have
already been described, there are several other useful routines that can
be accessed from assembly language. One of these is B_ONEXIT, which lets
you tap into BASIC's termination procedure.
When a BASIC program ends by running out of statements, or by using END,
STOP, or SYSTEM, BASIC makes a call to a central routine that in turn tells
DOS to end the program. If a fatal error occurs and there is no ON ERROR
handler, BASIC also calls a routine that prints an error message. B_ONEXIT
lets you tell BASIC the segment and address of a routine you want called
as part of the termination process. B_ONEXIT is supported only in
QuickBASIC version 4.5 and BASIC PDS.
One reason you might want to use B_ONEXIT is to ensure that interrupts
taken over by your assembler routine are restored properly. Taking over
interrupts will be described later in the section "Handling Interrupts."
Here's a program fragment showing how B_ONEXIT is set up and called:
Extrn B_ONEXIT:Proc ;declare B_ONEXIT as external
Push CS ;pass your code segment
Mov AX,Offset TermProc ;and the address of the routine
Push AX ; that is to be called
Call B_ONEXIT ;register it with B_ONEXIT
.
.
TermProc Proc ;this is the routine to be called
. ;do whatever you need to here
.
Ret ;don't forget to return!
TermProc Endp
BASIC's INTERNAL DATA
There are two internal variables BASIC maintains that you will find useful.
One is the current DEF SEG setting, and it is stored in the integer
variable named B$SEG. The other is the current color value that is used
by PRINT and CLS. The foreground and background colors are stored combined
in a single word named B$FBColors. The reason these are useful is because
you may want to change and then restore them from inside a BASIC
subprogram. Much of the benefit of reusable programming is lost if you
cannot put things back to the way they were originally.
For example, if you have written a BASIC routine that prints an error
message in bright red at the bottom of the screen, you will need to use a
subsequent COLOR command to put the color back to what it had been. But
what color do you use? The same holds true for a routine that changes the
current DEF SEG setting, perhaps before loading or saving a file using
BLOAD or BSAVE. If you cannot return that to its original value, extra
work is needed in the main program each time the routine is used.
Access to B$SEG requires a single assembler instruction, as shown in the
complete GetSeg function shown following. Declare and use GetSeg like
this:
DECLARE FUNCTION GetSeg%()
SavedSeg = GetSeg%
.
.
DEF SEG = SavedSeg
;GETSEG.ASM
.Model Medium, Basic
.Data
Extrn B$Seg:Word
.Code
GetSeg Proc
Mov AX,B$Seg ;load the value from B$Seg
Ret ;return with the function output in AX
GetSeg Endp
End
Because BASIC combines its colors into a single word, a few extra steps
are needed to separate them. Call GetColor like this:
CALL GetColor(FG%, BG%)
FG% and BG% are returned to you holding the current foreground and
background color values. Here's how GetColor works:
;GETCOLOR.ASM
.Model Medium, Basic
.Data
Extrn B$FBColors:Word
.Code
GetColor Proc, FG:Word, BG:Word
Mov DX,B$FBColors ;load the combined colors
Mov AL,DL ;copy the foreground portion
Cbw ;convert it to a full word
Mov BX,FG ;get the address for FG%
Mov [BX],AX ;assign FG%
Mov AL,DH ;load the background portion
Mov BX,BG ;get the address for BG%
Mov [BX],AX ;assign BG%
Ret ;return to BASIC
GetColor Endp
End
One unfortunate problem is that GetColor cannot be used in the editing
environment. When BASIC compiles a PEEK or POKE statement, it generates
inline code that loads ES with the segment from B$SEG, and then reads or
writes the data at the specified address. Therefore, the current segment
must be available to BASIC routines that use PEEK or POKE in a Quick
Library. But the color values are accessed only by routines in BASIC's
runtime library, so the information is not made available to procedures in
a Quick Library. Because of this issue, the GetColors routine is provided
on the accompanying disk only in the BASIC.LIB and BASIC7.LIB linking
libraries.
There are several other internal data items you may want to know about,
and one that I have found useful is called __osversion. This byte holds
the major DOS version number; for example, if DOS 3.x is running then
__osversion will hold the value 3. Even though it is trivial to query DOS
for the number, why bother since you can get it this way with a single Mov.
BASIC's INTERNAL ROUTINES
Besides the procedures and internal data I have described previously, there
are many others you will no doubt find useful. You can, for example, call
SETMEM prior to claiming memory from DOS. And although the B$ASSN routine
can assign any type of data from any other type including strings, a
simplified version is also present to assign to and from conventional
strings only.
As you have seen, the beauty of using BASIC's own routines is that
identical code can be used for both near and far strings. In either case,
the string descriptors are known to reside in DGROUP, and the internal
routines are designed to operate on those descriptors. You don't even have
to know which of the string libraries (near or far) is being used.
There are also several math routines that can be accessed directly,
including those that multiply, divide, and compare long integers. Even if
you know how to do that, it's always easier to call BASIC's routines. This
result in less code as well. And if you need to read the current cursor
position, you can access CSRLIN and POS(0) directly. In some cases, you
can't read that information from the BIOS, so calling BASIC is the only
reliable way to get it.
The following section documents the BASIC internal routines that I have
found useful when called from assembly language. I have purposely omitted
routines that handle BASIC commands such as PRINT, INKEY, GET, and PUT.
Even though several of these were described throughout the course of this
book, they have little relevance within a called assembler routine.
BASIC's internal services that follow are listed in alphabetical order,
based on their call names. Be sure to declare them as external procedures
in your routine's source code.
B$CPI4: Compare Two Long Integers
B$CPI4 expects two long integer arguments to be placed onto the stack by
value, and it returns the result of its comparison in the Flags register.
For example, to see if Var1 is greater than Var2 you'd use code like this:
Push Word Ptr [Var1+2] ;first push Var1's high word
Push Word Ptr [Var1] ;and then its low word
Push Word Ptr [Var2+2] ;next do the same for Var2
Push Word Ptr [Var2]
Call B$CPI4 ;compare them
Jg Label ;Var1 is indeed greater
Remember that long integers are compared by BASIC on a signed basis, so
you should use Jg or Jl rather than Ja or Jb. The letters CPI4 stand for
Compare Integer 4 bytes.
B$CSRL: CSRLIN Function
B$CSRL is called with no arguments, and it returns BASIC's current row in
AX as follows:
Call B$CSRL
. ;do what you want with AX
B$DVI4: Divide Two Long Integers
Like B$CPI4, B$DVI4 (Divide Integer 4 bytes) expects the incoming integer
arguments to be passed by value on the stack. The result is then returned
in DX:AX as a long integer:
Push Word Ptr [Var2+2] ;always push the high word first
Push Word Ptr [Var2] ;then the low word
Push Word Ptr [Var1+2] ;ditto for Var2
Push Word Ptr [Var1]
Call B$DVI4 ;divide them
. ;now DX:AX holds Var1 \ Var2
Notice that with B$DVI4, the divisor is pushed first onto the stack,
followed by the dividend.
B$FPOS: POS(0) Function
Even though the argument passed to BASIC's POS(0) is ignored, it is still
expected mainly for historical reasons. Therefore, you must push
something--anything--onto the stack before calling B$FPOS:
Push AX
Call B$FPOS
. ;now AX holds the column
As with all of BASIC's functions that return an integer, B$FPOS returns
the current column in AX. The leading F in FPOS stands for Function.
B$FRI2: FRE() Function
B$FRI2 (Free Integer 2 bytes) requires an incoming integer argument by
value on the stack, and for safety you should use this for the -1 and -2
variations only.
Using -1 reports the total amount of memory that is available to BASIC,
so you might use this before calling SETMEM to release memory for your own
uses. Although B$FRI2 uses an integer for an argument, it returns a long
integer in DX:AX. You can also use an argument of -2 to see how much stack
space is available:
Mov AX,-2
Push AX
Call B$FRI2
. ;now DX:AX holds the available stack space
B$RDIM: REDIM Statement
In most cases you will probably not find the ability to call REDIM directly
very valuable. One notable exception is explained later in the section
entitled "Reading the Array Descriptor," where I show how to size and then
load a string array with all of the files that match a given search
specification.
B$RDIM is fairly complicated to set up and call, because it accepts a
varying number of parameters. This is needed because BASIC accepts a
variable number of dimensions, and the same routine is used for all cases.
The following example shows how to prepare and call this routine when
resizing a one-dimensional array.
Mov AX,LBound ;first pass the lower bound value
Push AX
Mov AX,UBound ;then pass the upper bound
Push AX
Mov AX,ElementLength ;next the length of each element
Push AX
Mov AX,Features ;see the accompanying text for
Push AX ; information on these two items
Mov AX,Offset ArrayDescriptor
Push AX
Call B$RDIM ;call REDIM to do it
Chapter 2 described the array descriptor in detail, including the Features
word. However, you must not use REDIM to create a new array where none
existed before. Instead, you will read the current features from the
existing array descriptor, and pass the same values on again to B$RDIM.
This will be shown in context momentarily.
B$STDL: String Delete
You can call B$STDL to delete a string or string array element, and it
requires less code than assigning the string from another, null string.
The single argument is the address of a string descriptor:
Mov AX,Offset Descriptor
Push AX
CALL B$STDL
B$SETM: SETMEM Function
B$SETM expects a long integer argument by value on the stack; if the value
is negative then that much memory is released back to DOS, and thus taken
from your BASIC program. However, you should call B$SETM again later with
a positive value when you are finished, so the BASIC program can reclaim
that memory. Since SETMEM is a function, B$SETM also returns the amount
of memory currently available in the DX:AX register pair.
B$SASS: String Assign
Where B$ASSN is capable of assigning any mix of conventional and fixed-
length strings, B$SASS works with conventional strings only. However, it
requires only two parameters instead of six:
Mov AX,Offset Source$
Push AX
Mov AX,Offset Destination$
Push AX
CALL B$SASS
Note that if the destination string is not null, its current contents are
released after assigning it from Source$. This is the normal way that
strings are assigned, and B$ASSN also works like this.
Finding Other Routines
The routines just described are those that I personally have found to be
useful. Discovering other routine names and how they are called is in
fact quite simple. If you wanted to access, say, COMMAND$, you would write
a one-line BASIC program, and then examine the code that is generated using
Microsoft CodeView. CodeView lets you see which and how many parameters
are being passed as well as the routine name being called, making
exploration both easy and fun.
BASIC string functions such as COMMAND$ and ENVIRON$ return the DGROUP
address of the result string descriptor in AX, just like an assembly
language function you would write. If you do call a built-in BASIC
function, be sure to also pass its output descriptor to B$STDL (String
Delete) when you are done with it. Otherwise, the string space it uses
[and the temporary output descriptor] will never be released.
READING THE ARRAY DESCRIPTOR
Chapter 2 described the BASIC array descriptor in detail, and discussed
each of the components it contains. Understanding how an array descriptor
works opens many opportunities to assembly language programmers, because
it lets you write routines that accept an array passed with empty
parentheses. This was shown in the Sort routine introduced in Chapter 8,
although the techniques used there were not detailed.
As an example of the possibilities direct access to an array descriptor
offers, I will show a subroutine that accepts a file specification, and
returns a string array filled with the names of all matching files.
GetNames calls upon three internal BASIC routines: B$FLEN, B$RDIM, and
B$ASSN. B$FLEN returns the length of a string, and is used here to know
how long the file specification is. B$RDIM redimensions the passed string
array to the correct number of elements, based on the number of matching
file names that are found. B$ASSN then assigns each element to those
names.
This next short BASIC program shows how GetNames is set up and used.
DECLARE FUNCTION GetNames%(Array$())
REDIM Array$(1 TO 1) 'use REDIM, not DIM
Array$(1) = "*.*" 'any valid spec is okay
NumFiles% = GetNames%(Array$()) 'load all names at once
IF NumFiles% = 0 THEN 'were any files found?
PRINT "No matching files." 'no, say so and end
END
END IF
FOR X% = 1 TO NumFiles% 'yes, print each name
PRINT Array$(X%)
NEXT
PRINT NumFiles; "matching files were found"
As you can see, you must establish the array initially using REDIM. To
avoid the need for an extra parameter, the file specification is passed in
the first element of the array. Furthermore, GetNames returns the number
of files that matched as an integer result. If no files were encountered,
GetNames leaves the array as it was.
When GetNames is called, the array may already contain other data, and
it can have any legal upper and lower bounds. As long as the lowest
element number contains a valid search specification, the spec can be found
and the array will be redimensioned starting at element number one. The
GETNAMES.BAS demonstration program on the accompanying disk adds to this
short example by sorting the names after they are read.
A complete description of how GetNames works follows this source
listing.
;GETNAMES.ASM, loads a group of file names into an array
.Model Medium, Basic
Extrn B$RDIM:Proc ;this redimensions an array
Extrn B$ASSN:Proc ;this assigns a string
Extrn B$FLEN:Proc ;this returns a string's length
DTAType Struc ;define the DOS DTA structure
Intern DB 21 Dup (?) ;this is used by DOS internally
FAttr DB ? ;this holds the file attribute
FTime DW ? ;this holds the file time
FDate DW ? ;this holds the file date
FSize DD ? ;this holds the file size
FName DB 13 Dup (?) ;this holds each file name
DTAType Ends
.Data
DTA DB Size DTAType Dup (?) ;DOS places file info here
NumFiles DW 0 ;how many names were read
SpecLength DW 0 ;remembers file spec length
.Code
GetNames Proc Uses SI DI, Array:Word
Local Buffer[80]:Byte ;copy the spec here, add a zero
;-- Create a local Disk Transfer Area for our own use.
Lea DX,DTA ;show DOS where the new DTA goes
Mov AH,1Ah ;set DTA service
Int 21h ;call DOS to do it
;-- Read the array descriptor, get the search spec from the first element,
; then copy it to the stack appending a CHR$(0) byte (ASCIIZ string).
Mov SI,Array ;get address of array descriptor
Mov BX,[SI+0Ah] ;now BX holds adjusted offset
Mov AX,4 ;each element is four bytes long
Mul Word Ptr [SI+10h] ;multiply by first element number
Add BX,AX ;BX holds first element's address
Push DS ;push source segment and address
Push BX ; for call to B$ASSN later on
Xor AX,AX ;tell B$ASSN source is descriptor
Push AX ;using a value of zero
Push BX ;pass descriptor addr to B$FLEN
Call B$FLEN ;this returns the length in AX
Mov SpecLength,AX ;save length locally for a moment
Lea AX,Buffer ;get the destination address
Push SS ;pass the segment to assign into
Push AX ;and then the address
Push SpecLength ;we're assigning a fixed length
Call B$ASSN ;copy the file spec to the stack
Lea BX,Buffer ;retrieve start address of spec
Mov DX,BX ;copy to DX where DOS expects it
Add BX,SpecLength ;point just past end of string
Mov Byte Ptr [BX],0 ;and append trailing zero byte
;-- Count the number of names that match the search specification.
Mov AH,4Eh ;specify Find First matching name
Mov CX,00100111b ;this matches any type of file
Xor BX,BX ;BX counts the number of names
CountNames:
Int 21h ;see if there's a matching name
Jc DoneCount ;carry set means no more names
Inc BX ;otherwise, we found another one
Mov AH,4Fh ;find the next matching name
Jmp CountNames ;continue until there are no more
DoneCount:
Mov NumFiles,BX ;remember how many files we found
Or BX,BX ;did we fail on the first name?
Jz Exit ;yes, return a count of zero
;-- Now that we know how many file names there are, REDIM the string array.
Mov AX,1 ;specify an LBOUND of 1
Push AX ;pass that on to B$RDIM
Push BX ;and pass on the new UBOUND value
Mov AL,4 ;each descriptor takes four bytes
Push AX ;pass that on too
Mov BX,Array ;get array descriptor again
Mov AX,[BX+08] ;load the existing Features word
Push AX ;use that again for this call
Push BX ;show where array descriptor is
Call B$RDIM ;finally, redimension the array
;-- This is the main processing loop that reads and assigns each name
; that is found.
Mov AH,4Eh ;specify Find First matching name
Lea DX,Buffer ;load address of file spec again
Mov BX,Array ;get array descriptor address too
Mov BX,[BX+0Ah] ;reload the adjusted offset value
Add BX,4 ;BX is first descriptor address
Do:
Mov CX,00100111b ;specify any type of file again
Int 21h ;see if there's a matching name
Jc Exit ;carry set means no more names
Push BX ;otherwise, save the address
;-- Search for the zero that marks the end of this name.
Mov DI,Offset DTA.FName
Push DS ;in anticipation of call below
Push DI ;DI too while the address handy
Push DS ;ensure that ES=DS
Pop ES
Mov CL,13 ;search up to 13 characters
Repne Scasb ;do the search
Mov AL,CL ;save the remainder in AL
Mov CL,13 ;calc number of chars to copy
Sub CL,AL ;the answer is now in CX
Dec CX ;don't include the zero byte
Push CX ;pass that on to B$ASSN
Push DS ;show where destination string is
Push BX
Xor AX,AX ;zero means B$ASSN is assigning
Push AX ; to a conventional string
Call B$ASSN ;assign this element to the name
Pop BX ;retrieve the descriptor address
Add BX,4 ;point to the next element
Mov AH,4Fh ;specify Find Next matching name
Jmp Do ;and keep on keepin' on
Exit:
Mov AX,NumFiles ;assign the function output
Ret ;return to BASIC
GetNames Endp
End
GetNames begins by declaring the three BASIC routines it will call as being
external. Next the DTA structure is defined, to simplify access to the
file name address when it assigns each element in the string array. The
only data items are the DTA itself, two working variables, and the local
stack buffer. Since the incoming file specification needs to be converted
to an ASCIIZ string for DOS, GetNames copies that specification into Buffer
and then appends a CHR$(0) zero byte to the end.
Once the DTA has been established, the next step is to read the file
specification passed in the first element, and copy it into local storage.
B$FLEN is used to obtain the length of the string, so GetNames will know
how far into the buffer the zero byte will be placed. The last preparatory
steps call B$ASSN telling it to copy from a conventional string (the array
element) to a fixed-length string (Buffer), and then store the zero byte.
The actual body of the program is broken into two portions. The first
simply calls DOS repeatedly to count the file names, to know how many
elements are needed. The count is then saved in NumFiles; if none were
found GetNames exits without doing anything else. Otherwise, the incoming
string array is redimensioned from 1 to the number of files.
The second portion again reads each file name through DOS, but this time
the names are actually assigned to the array elements using B$ASSN. This
time, however, B$ASSN assigns a conventional string from the fixed-length
string portion of the DTA. Since the source is now of a fixed-length,
GetNames needs to know how long each name is. The longest possible name
is 13 bytes long (eight for the name, a period, three for an extension, and
one more for the terminating zero byte). Therefore, ES:DI is set to point
to the start of the DTA, AX is set to zero to search for the zero byte, and
CX is loaded with the number of characters to scan.
Once the zero is found--and it always will be--the count that remains
in CX is subtracted from 13 to obtain the actual length of the current
name. Because that calculation includes the unwanted CHR$(0), CX is
decremented by one.
There is one small related trick that bears explaining. Just before the
call to B$RDIM, AX is loaded with the number 1, to specify that as the
first element number. This three-byte instruction sets AL to 1, and clears
AH to 0. Three lines below that only AL is assigned, which is sufficient
because we know that AH is already zero. Because the number being assigned
is one byte long, assigning AL requires only two bytes.
Admittedly, the savings is small, but the affect on code readability is
minimal once you know about such tricks. And a byte saved is always
welcome in assembly language programming. The same trick is used when
setting CL to 13, where CH is known to be zero after assigning the file
attribute of 00100111b to all of CX.
HANDLING INTERRUPTS
===================
The last programming technique I want to describe is writing an interrupt
handler you can attach to a BASIC program. There are several applications
for this, such as tapping into the timer interrupt to display an on-screen
clock. Instead of having to constantly print TIME$ during your INKEY$
input loops, such a routine would act as a sort of TSR, getting control at
each timer tick and displaying the time automatically.
The example I will show here takes over the keyboard interrupt, and
disables the Ctrl-Alt-Del key sequence. This lets you prevent rebooting
with its corresponding loss of data, should someone press those keys
inadvertently (or on purpose!). NoReboot is called as follows:
CALL NoReboot(BYVAL InstallFlag%)
If InstallFlag is non-zero, you are telling NoReboot to install itself and
take over the keyboard interrupt to prevent rebooting. An argument of
zero instead unhooks the interrupt, and re-enables those keys. Although
you could certainly modify NoReboot to use BASIC's B_ONEXIT service to
deinstall itself automatically, I have left that feature out on purpose in
the interest of clarity. This also lets you activate NoReboot selectively
in your program, since there is no way to revoke a request to B_ONEXIT.
;NOREBOOT.ASM, traps Ctrl-Alt-Del within a BASIC program
.Model Medium, Basic
.Code
NoReboot Proc Uses DS, InstallFlag:Word
Cmp InstallFlag,0 ;are they asking to install?
Je Deinstall ;no, so deinstall it
Cmp CS:Old9Seg,0 ;yes, are we already installed?
Jne Exit ;yes, and don't do that again!
Mov AX,3509h ;ask DOS for current Int 9 vector
Int 21h ;DOS returns it in ES:BX
Mov CS:Old9Adr,BX ;save it locally
Mov CS:Old9Seg,ES
Mov AX,2509h ;point Int 9 to our own handler
Mov DX,Offset NewInt9
Push CS ;copy CS into DS
Pop DS
Int 21h
Exit:
Ret ;return to BASIC
;-- Control comes here when a key is pressed or released.
NewInt9:
Sti ;enable further interrupts
Push AX ;save the registers we're using
Push DS
In AL,60h ;read the keyboard scan code
Cmp AL,83 ;is it the Delete key?
Jnz Continue ;no, continue on to the BIOS
Xor AX,AX ;see if Alt and Ctrl are pressed
Mov DS,AX ;by looking at address 0:417h
Mov AL,DS:[417h] ;get shift status at 0000:0417h
Test AL,8 ;is Alt key depressed?
Jz Continue ;no, continue on to the BIOS
Test AL,4 ;is Ctrl key depressed?
Jz Continue ;no, continue on to the BIOS
In AL,61h ;send an acknowledge to keyboard
Mov AH,AL ;otherwise the Ctrl-Alt-Del
Or AL,80h ; keystroke will still be
Out 61h,AL ; hanging around the next time
Mov AL,AH ; a program asks for a key
Out 61h,AL
Mov AL,20h ;indicate end of interrupt to the
Out 20h,AL ; 8259 interrupt controller chip
Pop DS ;ignore, simply return to caller
Pop AX
Iret ;use this special Ret when
; returning from an interrupt
Continue:
Pop DS ;restore the saved registers
Pop AX
Jmp DWord Ptr CS:Old9Adr ;continue on to the BIOS by
; jumping to the address
; that was saved during
; initialization
DeInstall:
Mov AX,2509h ;restore original Int 9 handler
Mov DX,CS:Old9Adr ;from segment and address saved
Mov DS,CS:Old9Seg ; earlier
Int 21h ;DOS does this for us
Mov CS:Old9Seg,0 ;clear this as an installed flag
Jmp Short Exit ;and then exit back to BASIC
NoReboot Endp
Old9Adr DW 0 ;remembers original Int 9 address
Old9Seg DW 0 ;these must be stored in the code
; segment because DS is undefined
; when NewInt9 receives control
End
The first thing NoReboot does is look to see if the caller is installing
or deinstalling. If installation is requested, the saved Interrupt 9
segment is checked, to be sure that it holds the initial value of zero.
It is important to prevent multiple installations, because installing saves
the current interrupt handler's address. If NoReboot installed itself
twice, it would save its own address on top of the original BIOS handler's
saved address. And once that address is lost, it is impossible to restore
it again later.
Assuming it is safe to be installed, the next step is to ask DOS for the
current interrupt handler's address using service 35h. This service
expects the service number in AH, and the interrupt number in AL. To save
a byte, both values are loaded at once. Service 35h returns the segment
and address in ES:BX, and these are saved in the code segment. Because the
original address will be called from within the interrupt handler, CS is
the only register whose contents are known. Accessing data in DGROUP is
more difficult, because an interrupt can occur at any time, and DS will
likely not be holding the correct segment. [That is, execution could be
at any point in the program when Ctrl-Alt-Del is pressed, including within
a routine that has changed DS. So when NoReboot receives control it can't
be certain that DS holds the segment for .Data variables it has defined.]
Once the original interrupt handler address has been saved, NoReboot
calls DOS again, but this time to assign the segment and address of its
replacement handler in the interrupt vector table. It is easy to access
the interrupt vector table directly using Mov instructions, but it is even
easier to have DOS do that.
Finally, NoReboot returns to the calling BASIC program, and all
subsequent key presses are now routed to the NewInt9 procedure.
NewInt9 must perform a few tricks, partly because it is handling a
hardware interrupt. All interrupt handlers begin with the instruction Sti,
which tells the 8088 to allow further interrupts to occur and be processed.
Next, the two registers being used are saved on the stack, so they can be
restored again later. Because a keyboard interrupt can occur at any time
interrupting the process that is currently running, it is imperative that
you not alter any aspect of the 8088's current state. This includes the
settings of the Flags register as well. However, the Flags register is
saved automatically by the 8088 as part of its handling of interrupts, so
the flags don't have to be saved or restored manually using Pushf and Popf.
The next sequence of instructions reads the key that was pressed from
the keyboard's I/O port (60h), and compares that to the scan code for the
Del key. If any other key was pressed, NoReboot jumps to the original
keyboard handler in the ROM BIOS. Otherwise, it examines low memory to see
if both the Ctrl and Alt keys are also currently pressed. Unless all three
conditions are met, control passes on to the BIOS. But if Ctrl-Alt-Del is
pressed, NoReboot handles the keystroke entirely on its own and ignores it.
In that case DS and AX are restored, and NoReboot exits back to the
underlying program.
Notice the special form of return command, Iret (Interrupt Return).
Like a conventional far return, Iret pops the address and segment to return
to from the stack, but it also pops the Flags register that was stored
there by the 8088 automatically.
The final section of code restores the original interrupt vector, and
clears the Old9Seg variable to zero. This lets NoReboot know that it is
not installed, in case you call it again later.
This same technique can be applied to handle other interrupt services,
and I encourage you to experiment on your own. You could, for example,
write a routine that takes over the communications interrupt, and displays
a flashing box in a corner of the screen whenever characters are received.
Likewise, you could modify this routine to create an on-screen display of
the Caps Lock and Num Lock state. Each time one of those keys is pressed
you would either print or clear a status message.
DEBUGGING WITH CODEVIEW
=======================
As useful as CodeView can be for a purely BASIC program, it is even more
necessary when writing in assembly language. CodeView lets you step
through the code that BASIC generates to set up and call your subroutine,
and then step through the routine a line at a time. Being able to watch
your program as it executes helps you to quickly zero in on any problems.
Further, CodeView shows you the current CPU register contents, as well as
the value of memory locations about to be read from or written to.
To debug an assembly language subroutine with CodeView, you must first
assemble it using the /Zi option switch:
masm routine /zi;
Then you link the routine to your BASIC program using the /Co option. Of
course, the BASIC program must also have been compiled using /Zi:
bc program /o /zi;
link program routine /co;
Finally, you start CodeView specifying the name of the BASIC program:
cv program
Once the BASIC source code is showing on the screen you can step and trace
through it as described in Chapter 4. As with BASIC subprograms and
functions, to step into an assembler routine you press F8 at the CALL
statement. If the routine is designed as a function you instead press F8
at the line in which the function is referenced.
Once CodeView has traced into the routine, you can press F3 to view the
source code only, the assembly code only, or both intermixed. I usually
prefer to view only my original source, but that hides the data memory
addresses that MASM and LINK assigned. Usually you will not need to know
those addresses, but there are times when this can be helpful. For
example, when a program is not working correctly, the bug could be caused
by a different portion of the program overwriting the named variables.
Besides the F3 key, you can also use F4 and F7, and these have the same
meaning as the same keys when used in the BASIC editor. Indeed, debugging
an assembly language subroutine is quite similar to debugging a BASIC
program as far as which keys are used.
MASM 6.0 ENHANCEMENTS
=====================
All of the discussions in this chapter have focused on using MASM version
5.1. However, Microsoft's more recent version 6.0 introduces a number of
significant changes and new features. Perhaps the most useful new feature
in this release is the greatly improved documentation. The manuals that
came with past versions of MASM were very dry, containing reams of facts
but no practical advice or guidance. The new documentation include both
facts and programming tips, and this addition is welcome indeed.
If you already have existing assembly language source code, you may have
to change it to accommodate the new MASM 6.0 conventions. In particular,
MASM's handling of data structures has changed substantially, and in many
cases code that used to work correctly no longer does. However, you can
optionally use the /Zm command line switch, to tell MASM 6.0 to behave like
the earlier 5.1 version.
A new MASM.EXE program launcher is also included to offer a similar
capability. Where older versions of MASM were named MASM.EXE, the new
program is called ML.EXE. The MASM.EXE that now comes with MASM 6.0 simply
passes the /Zm option on to ML, along with some other option switches that
are needed to tell ML to mimic the older assembler's behavior.
IMPROVED ASSEMBLY OPTIMIZATIONS
Before MASM 6.0, a conditional jump was limited to a distance no greater
than 128 bytes earlier or 127 bytes farther ahead in the code. When there
was no way to restructure your code to accommodate this inherent 8088
limitation, you had to use a conditional jump around another unconditional
jump like this:
;if AX < 12 go to FarLabel
Cmp AX,12 ;compare AX to 12
Jnl NearLabel ;jump if not less over far jump
Jmp FarLabel ;perform the far jump
NearLabel:
. ;program continues
.
. ;this label is more than
FarLabel: ; 127 bytes past Jnl
MASM 6.0 avoids this limitation and lets you use Jl to the far label
directly, although it really just replaces your use of Jl with code
equivalent to that shown above.
Another, similar optimization affects unconditional jumps. As I
mentioned earlier, each time MASM 5.1 encounters a label in your source
code, it remembers its address in the resultant object code. Then if you
jump backwards to that label later, MASM knows if it can use the shorter
two-byte form of the Jmp instruction. But a forward jump to a near label
requires you to explicitly state Jmp Short to obtain this code savings,
since MASM 5.1 does not yet know the target label's address. Without
Short, MASM 5.1 uses a long jump on a trial basis. If the jump turns out
to be within the near range MASM goes back and patches the code to a short
jump followed by a byte-wasting Nop (No Operation) instruction.
MASM 6.0 avoids this problem by processing your source file in multiple
passes. That is, MASM reads your code and assembles what it can, using far
jumps when the target address has not yet been encountered. Then it
processes that intermediate code again modifying its earlier output as
appropriate. If a three-byte jump can be replaced with the two-byte
version, MASM 6.0 rewrites the code sliding subsequent instructions back
a byte. MASM 6.0 is called an *n-pass assembler*, because as many passes
as needed are performed until the code is as small as possible.
NEW SIMPLIFIED DIRECTIVES
Besides the improved optimizing, MASM 6.0 offers several features borrowed
from high-level languages. These include .IF, .ELSE, and .ELSEIF; .WHILE
and .ENDW; and .REPEAT and .UNTIL. Unfortunately, these new constructs are
modeled after the C language, and provide little if any clarification to
BASIC programmers. For example, you can now write code such as this:
.IF (AL < "0") || (AL > "9")
which is equivalent to this BASIC statement:
IF AL < ASC("0") OR AL > ASC("9")
Even worse, the MASM manual does not document each directive showing
precisely what it does to your code.
Like C, BASIC's AND is replaced with a double ampersand (&&), testing
for equality uses a double equals sign (==), and NOT is replaced with an
exclamation point (!). Therefore, you could write assembly language source
statements like these next two examples:
.IF (AX != 14) && (BX < 10) ;IF AX <> 14 AND BX < 10 THEN
Mov AX,SomeVar ;divide SomeVar by CX
Cwd
Div CX
Mov SomeVar,AX
.ENDIF
.REPEAT
Mov AH,1 ;ask for a keyboard character
Int 21h ;through DOS
.UNTIL (AL == 13) ;loop until they press Enter
PROTO and INVOKE are two other new simplified directives, and it's hard
for me to recommend using them for similar reasons. PROTO mimics C's
function prototype capability, and lets you define a called procedure and
its arguments. INVOKE then calls that routine passing the arguments you
give it. To define a procedure called, say, MyProc, you would use PROTO
like this:
MyProc PROTO Var1:Word, Var2:Word, Var3:DWord
Then to call MyProc you use INVOKE as follows:
INVOKE MyProc, BX, 100, LongVar
Thus, PROTO and INVOKE are very similar to DECLARE SUB and CALL in BASIC.
The problem is that you have no way to know what code MASM generates for
this command unless you create a sample program, assemble it, and examine
the result using CodeView. In particular, how does the value 100 used here
get onto the stack? As it turns out, assembling the preceding INVOKE
command results in the following code:
Push BX
Mov AX,100
Push AX
Push Word Ptr [LongVar+2]
Push Word Ptr [LongVar]
As you can see, even if AX is holding an important value, its contents are
destroyed when MASM assigns the value 100 prior to placing it on the stack.
While I applaud Microsoft's attempts to make assembly language easier to
use, such behavior can and will introduce subtle bugs. These bugs can be
even harder to track down than usual, because you did not make the coding
error, the assembler did! Since the whole point of programming in assembly
language is to control fully what the CPU is doing, such hidden behavior
can have disastrous effects.
One new feature that I do find useful, however, is the ability to
continue a line with a trailing comma. Often, a single source statement
will extend into the comments column, spoiling the appearance of your
listing. You can now avoid this by placing a comma in the middle of a
logical line, and then continuing the remainder of the statement on the
next line.
Another very useful feature is MASM 6's ability to accept wild cards on
the command line. For example, you can assemble all of the files in the
current directory using the command masm *.asm;.
TRICKS OF THE TRADE
===================
The final topic I want to present is a variety of assembly language
programming short cuts and other techniques I have developed over the
years. In preceding sections you saw how Xor or Sub can be used to clear
a register, using less code than Mov. And if you know that the high-byte
portion of a register or memory variable is already zero, you can save a
byte by assigning only the lower byte. And to clear both AX and DX you can
use Xor with AX, and then Cwd to extend the zero into DX using only one
additional byte. As you might imagine, there are many other ways to be
clever in assembly language.
MINIMIZE CODE TO ACCESS PARAMETERS
When parameters are accessed within an assembly language subroutine, the
usual way to get at them is through BP. Even when you use MASM's
simplified directives, code to push BP, assign it from SP, and then
reference the address on the stack is added to your program. In that case,
the steps are simply hidden from you. Because BASIC (and indeed, every
high-level language) requires you to preserve BP, one byte each is needed
for the Push and Pop instructions.
You can eliminate that overhead by taking advantage of the fact that the
stack is always kept in DGROUP, and that SS and DS are equal. The trick
is to use BX as a stack reference, because it doesn't need to be preserved.
Unfortunately, this precludes using the simplified methods for parameter
access. But when speed or code size are paramount or you have many
routines, stack addressing via BX affords a real savings. Here's how you
will design the routine, using an example that accesses an incoming string:
GetString Proc ;one parameter, not shown
Mov BX,SP ;address the stack manually using BX
Mov BX,[BX+04] ;get the address for the string
Mov CX,[BX] ;get the length of the string
Jcxz Exit ;quit if the string is null
Mov BX,[BX+02] ;get address of first character
Exit:
Retf 2 ;specify far return with 2 bytes
GetString Endp
End
Because BP has not been pushed onto the stack, the incoming string
descriptor address is at [BX+4] rather than [BX+6]. Other than that, the
remainder of the routine proceeds as usual.
BYTE SAVERS
Another useful trick lets you save a byte when adding two to a variable.
As you know, Inc and Dec when used with a register are always better than
Add and Sub, because they are one-byte instructions. Therefore, two Inc
or Dec commands in a row are still better than Add AX,2 which requires
three bytes. However, you must never do this with SP. The stack pointer
must always hold an even number, and it is possible that an interrupt could
come along after the first Inc or Dec, but before the second has executed.
Which brings up a related byte saver.
If you need only a single word of local stack storage, don't use Sub
SP,2 to allocate the space and Add SP,2 later to clear it. Instead, simply
use Push AX, or Push with any other register. Likewise, just before
returning to BASIC, pop any register that doesn't return information, such
as CX or BX.
Rep Always Clears CX
Another trick you can take advantage of is that CX is often zero after a
repeating string command that uses Rep. Zero is a common value in assembly
language programming, and you can usually save a byte by using a register
instead of a constant zero. In particular, if you are copying a file name
to a buffer and adding a CHR$(0) to the end, you can use code like this:
.
. ;set up DS:SI and ES:DI here
Mov CX,NumBytes
Rep Movsb
Mov [DI],CL ;tack a zero byte onto the end
.
.
This trick is made even more valuable by the fact that DI is left pointing
at the byte just past the data that was just copied. Of course, CX is not
necessarily zero after Repe or Repne, because those forms of Rep can
terminate before CX is exhausted.
Use AX Where Possible
Another little-known fact is that memory operations that use AX are one
byte smaller than equivalent operations on any other register. That is,
Mov BX,KeyCode results in four bytes of code, whereas Mov AX,KeyCode
creates only three. I often use the DOS DEBUG program for quick tests,
just to see which sequence of instructions results in less code. Since
DEBUG does not let you specify a variable name, use [100] or any other
address instead:
-a 100
-####:0100 Mov AX,[100]
-####:0103 Mov BX,[100]
-####:0107 <press Enter to stop assembling>
-u 100,106
####:0100 A10001 MOV AX,[0100]
####:0103 8B1E0001 MOV BX,[0100]
-q
This sample session tells DEBUG to begin assembling at address 100 (the
default for .COM files), and then assemble the two instructions shown.
When you are done press Enter at the dash prompt, and then unassemble the
results and quit. As you can see, using AX creates one less byte of code.
Multiplying and Dividing By a Power of 2
Because of the way binary numbers are organized, shifting the bits left
or right can provide a very fast way to multiply or divide by a power of
two. And because the bit shifting commands can be used with all but the
segment registers, this can also save you from having to copy the data to
AX or DX:AX first. To divide a register by two simply shift the bits right
one position:
Shr CX,1
And to multiply by two shift them left:
Shl SI,1
If you need to multiply or divide by four, eight, sixteen, and so forth,
the shift count must first be placed into the CL register:
Mov CL,5 ;prepare to divide BP by 32
Shr BP,CL
On 80186 and later processors you can specify a shift count directly.
Unfortunately, this doesn't work with an 8088, so CL must be used. Still,
multiplying and dividing are extremely slow instructions on an 8088, so the
added setup will be more than offset if speed is the primary factor.
Low Memory is at Segment Zero
Another useful byte saver is to treat the BIOS data area in low memory as
being at segment zero, instead of the more commonly used segment 40h. By
convention, the BIOS data area is said to reside at segment 40h, even
though a number of segment/address pairs can be used to access that data.
I mentioned this briefly in Chapter 11, in the discussions about using
BASIC's CALL Interrupt. Since Xor or Sub can be used to clear a register
to zero with one byte less code than assigning it a value of 40, I use this
technique frequently:
This example generates 9 bytes:
Xor AX,AX
Mov DS,AX
Test Byte Ptr [417h],8 ;see if the Alt key is depressed
And this example creates 10 bytes:
Mov AX,40h
Mov DS,AX
Test Byte Ptr [17h],8
Scanning An ASCIIZ String
Because ASCIIZ strings are used in programs that access DOS services,
searching those strings to find the end is a common operation. For
example, the GetNames function does this to determine the length of each
file name before assigning it to elements in the incoming string array.
In that routine CX is assigned to 13, which is the maximum length a file
name can be. Since CX is decremented for each character that is examined,
the length is calculated by subtracting CX from 13, which requires an extra
register.
As long as you are certain that a zero byte is present, you can use a
clever trick to determine directly the number of bytes that were searched.
Instead of loading CX with the maximum number of bytes to scan, assign it
to -1. As each character is searched CX is decremented, which results in
a negative version of the number of bytes. Then the NOT instruction can
be used to revert that to a positive number:
Mov ES,Segment ;point ES:DI to the start of the data
Mov DI,Address
Cld ;ensure that scanning is forward
Mov CX,-1 ;set CX to -1
Mov AL,0 ;search for a zero byte
Repne Scasb ;scan the string
Not CX ;convert to a positive number
Dec CX ;don't include the zero byte itself
Mov AX,CX ;now AX holds the length of the string
As you learned in Chapter 2, BASIC's NOT instruction flips all of the bits,
converting ones to zeros and vice versa. The assembly language version
works the same way, and can be used with registers or memory locations.
CYCLE SAVERS
Besides savings bytes when possible, most assembly language programmers
also like to save clock cycles. Every assembler instruction requires a
certain amount of CPU timing cycles to execute, although there are other
factors that also affect the actual throughput of a given piece of code.
But instructions with the fewest number of clock cycles as published by
Intel are always faster than those that require more cycles.
Move and Store Words Instead of Bytes
One very effective speed enhancement is to copy and store words when
possible, instead of bytes. On 80286 and later processors, words are moved
and stored as quickly as bytes. Therefore, moving 50 words is much faster
than moving 100 bytes. If you know ahead of time how many bytes are going
to be processed and that the number is even, you can simply load CX with
half the value, and use Rep Movsw or Rep Stosw instead of Rep Movsb or Rep
Stosb. [This trick can be used even if the program runs on an 8088, but
the speedup only occurs with 80286 and later CPUs.] With only a little
added code you can also use this technique to determine at runtime if an
odd byte needs to be processed. Here's one way to do that:
Shr CX,1 ;divide CX by 2
Rep Movsw ;copy the words
Jnc Done ;the Carry Flag is clear
Movsb ;copy the odd byte
Done:
. ;program continues
.
First, CX is divided by 2, and the odd bit, if there was one, is stored
by the CPU in the Carry Flag. Then the data words are copied to their
destination. Finally, the Carry flag is tested and the program either
copies a single additional byte or skips over that command.
A Jump Not Taken is Faster Than One That is
And this brings us to yet another cycle saver. In some cases the Jnc will
be executed, and in others it will not. And in most programs, the chances
of either happening are about fifty-fifty. But if you know ahead of time
that a particular action will happen less often than another, you can take
advantage of another 8088 fact: A jump not taken is always faster than one
that is taken.
Each time the 8088 jumps to a new location or calls a procedure, it
discards its *pre-fetch queue*. The pre-fetch queue is a small area of
memory on the CPU itself that holds the next few instructions to be
executed. In many cases, the 8088 can do several things at once. So while
it is adding or subtracting numbers, it simultaneously fetches instruction
bytes from your code, in anticipation of what it will do next. This lets
the CPU act on the subsequent instructions very quickly, because they are
already in its own local on-chip memory. Just as data in registers can be
accessed faster than data that must be read from memory, so too can
instructions that are already in the CPU.
But when execution branches to a new location, any bytes present in the
pre-fetch queue are obsolete. Therefore, the 8088 must read the new bytes
at the new location, which takes additional time. If you have a routine
that makes a test repeatedly within a loop you should change the logic as
necessary, to branch on the less likely situation. That is, instead of Jne
you might use Je, or vice versa.
MISCELLANEOUS TECHNIQUES
One very powerful technique you will surely find useful is self-modifying
code. As its name implies, self-modifying code actually writes new
instructions into its own code segment, and this is useful in a variety
of situations. For example, if you are writing a routine that accepts a
variable number of parameters this lets you patch the Ret instruction to
be Ret 2, Ret 4, and so forth.
One warning, however, is related to the pre-fetch queue. If a byte or
word has already been read into the CPU, changing it in the code segment
has no effect. Worse, there is no way to know for certain which bytes will
have already been read, because the size of the pre-fetch queue has grown
with each new CPU from Intel. For example, only four bytes are allocated
for a pre-fetch queue on an 8088, but the 80386 uses 16 bytes.
In general, if the code you are patching is located at least a few dozen
bytes farther in the program, you should be safe. Such self-modifying code
was used in the SORT.ASM routine shown in Chapter 8, to let the same code
sort either forward or backward. There, the bytes that represent Jae and
Jbe were assigned to AL and AH, and the code was patched based on the
incoming sort direction. Since the patching takes place a hundred or so
bytes earlier in the program, it is unlikely that this routine will fail
with future processors.
Static-Free CGA Text Display
The final technique you will find useful is writing to CGA text mode video
memory without creating a disturbance. When IBM designed the original CGA
adapter they skimped on the design, using circuitry that shares a single
address line for both the 8088 CPU and the video hardware that updates the
screen. Even when a program is not reading from or writing to display
memory, that memory is still read periodically by the display adapter and
sent to the monitor. Therefore, accessing that memory directly from an
assembly language routine creates a disturbing burst of static that is
visible on the monitor. This is caused by the conflict of the CPU and the
video adapter accessing the same video memory addresses at the same time.
Newer CGA adapters employ a dual-port design that arbitrates
simultaneous read and write requests, thereby eliminating this problem.
And, of course, EGA and VGA adapters are much more sophisticated than the
CGA, and fortunately also more common these days. However, you can avoid
the screen disturbance on older CGA adapters by synchronizing your reading
and writing with the horizontal retrace timing.
As you undoubtedly know, the image on a CRT is drawn by scanning a
single dot horizontally across each successive row. This happens so
quickly that the eye perceives the moving dot as an entire image. After
each row is drawn, the dot is turned off, quickly placed at the start of
the next row below, and then turned on again. By writing to the screen
only while the dot is turned off you can hide the memory conflicts that
cause static.
The short code fragment below shows how to synchronize video writing
with the CGA's horizontal retrace. In a windowing routine that also needs
to read video memory, you would use the same technique just before each
byte or word is read.
.
.
Mov SI,Descriptor ;get the incoming descriptor address
Mov CX,[SI] ;the string's length goes in CX
Mov SI,[SI+2] ;and the address of the data in SI
Mov AX,&HB800 ;load ES with the CGA video segment
Mov ES,AX ;through AX
Xor DI,DI ;point DI to the upper left corner
Mov AH,Color ;load color parameter (passed BYVAL)
Jcxz Done ;don't try to print a null string!
No_Retrace:
In AL,DX ;get the video status byte
Test AL,1 ;test the horizontal retrace bit
Jnz No_Retrace ;if doing retrace, wait until done
Cli ;disable interrupts until we're done
Retrace:
In AL,DX ;get the status byte again
Test AL,1 ;are we currently doing a retrace?
Jz Retrace ;no, wait until we are
Lodsb ;load the current character
Stosw ;store the character and attribute
Sti ;re-enable interrupts
Loop No_Retrace ;loop until the string is printed
Done:
. ;program continues or exits here
.
The current horizontal retrace status can be read using the In instruction,
and then masking off all but the lowest bit. To protect against the case
where the print loop is entered just as the retrace is about to end, this
routine waits until a new period has just begun. This is not unlike the
empty loop used in the benchmark examples in Chapter 9, that waited for a
new system clock cycle to begin.
SUMMARY
=======
In this final chapter you have learned what assembly language programming
is all about, and how it can help you as a BASIC programmer. There is no
doubt that using assembly language is more tedious than BASIC, but the
overall methods and code structures are similar.
You learned about the 8088's registers, and why operations that use them
are faster than similar operations on memory variables. The string
instructions are particularly useful, because they are very small and do
several things at once. Coupled with the Rep prefix these commands can
replace many separate Mov and Inc and Cmp statements. You also learned how
to perform simple calculations in assembly language, and an example showed
how to translate simple BASIC integer and floating point expressions.
This chapter explained how the stack operates, and how procedures are
designed to accept passed parameters. The new simplified directives
introduced with MASM 5.1 eliminate the need to define segments and figure
parameter stack displacements in your routines. This chapter also
explained how to call DOS and BIOS interrupts from assembly language.
You learned how to access every kind of data a BASIC program can pass
to a routine, including near and far strings, integers, and even floating
point values. The section that described arrays showed how to access both
near and far data, and even huge arrays that span multiple segments.
Besides conventional called procedures, you also learned how to create
functions that can return any type of data. Several innovative techniques
were presented, including a method for creating a single procedure that can
work with both near and far strings, and even with different versions of
the BASIC compiler. Equally innovative are the methods that show how to
write floating point instructions and tie them into BASIC's software
emulator. And if you are not certain how to code a particular floating
point instruction, you can create a short BASIC program and then examine
its code using CodeView.
This chapter explained many of MASM's features, such as initialized
data, conditional assembly, and defining structures and macros. In
particular, macros can greatly simplify coding redundant instructions and
data definitions. Furthermore, MASM can calculate data addresses and
lengths automatically, reducing your work when the data must be changed
later on.
Because so many different data items all compete for the same 64K near
memory segment, it is often desireable to store working variables on the
system stack. Likewise, when large amounts of data are involved, variables
and tables can be stored in the code segment. Both of these techniques
were described in depth, and accompanying examples showed how to do this
in context.
Several of BASIC's most useful internal variables and procedures were
described, showing their public names and parameter requirements. The
GetNames function brought all of this information together, showing how to
read an array descriptor, redimension a string array, and assign individual
elements--all using code that works identically with both near and far
strings.
You also learned how to write an interrupt handler that can be installed
and deinstalled from within a BASIC program. The example showed how to
take over the keyboard interrupt; however, the same technique can be
applied to nearly any other hardware or software interrupt as well.
Finally, this chapter described many useful tricks and techniques that
help to reduce the size of your assembly language routines, and also make
them faster. Many operations that use the AX register result in less code
than the same operations using other registers. And when moving or storing
contiguous data, accessing the data as words instead of bytes can sometimes
yield a nearly two-fold speed improvement. When in doubt about which of
several sequences of code is smaller, you can use the DOS DEBUG utility to
quickly determine that.