QB CULT MAGAZINE
Vol. 3 Iss. 2 - November 2003

BASIC Techniques and Utilities, Chapter 11
Accessing DOS and BIOS Services

By Ethan Winer <ethan@ethanwiner.com>

BASIC is arguably the most capable of all the popular high-level languages available for the PC. However, one area where all PC languages are weak is when accessing DOS and BIOS system interrupts. Previous chapters included subroutines and functions that access DOS interrupt services using CALL Interrupt, but in most cases with little explanation. This chapter explains what interrupts are, how they are accessed, and how they return information to your program.

Only assembly language--the native language of the processor in every PC--can directly access interrupts. Assembly language programmers use the Int instruction, which transfers control to an *interrupt service routine*. An Int instruction is nearly identical to a conventional CALL statement, except a slightly different mechanism within the computer's hardware is used to implement it.

BASIC lets you access system interrupts by providing a pair of assembly language interface routines called Interrupt and InterruptX. These routines accept the interrupt number and other parameters the interrupt requires, and they then perform the actual interrupt call. InterruptX is similar to Interrupt; the only real difference is that it lets you access two additional CPU registers.

What is an Interrupt?

The IBM PC family of personal computers supports two types of interrupts: hardware and software. A hardware interrupt is invoked by an external device or event, such as pressing a key on the keyboard. When this happens, a signal is sent from the keyboard hardware to the PC's microprocessor telling it to stop what it's currently doing and instead call one of the routines in the PC's BIOS.

For example, while your PC is currently copying a group of files you may type DIR simultaneously, to display the results when the copying has finished. Even though DOS is reading and writing the files, you interrupt those operations for a few microseconds each time a key is pressed. The BIOS routine that handles the keyboard interrupt is responsible for placing the keystrokes into the PC's 15-character keyboard buffer. Then when DOS has finished copying your files, the DIR command will already be there. Because there is a direct physical connection between the keyboard circuitry and the PC's microprocessor, you are able to interrupt whatever else is happening at the time.

A software interrupt, on the other hand, doesn't really interrupt anything. Rather, it is a form of CALL command that an assembly language program may issue. Just like the CALL command in BASIC that transfers control to a subroutine, a software interrupt is used in an assembly language program to access DOS and BIOS services. Although assembly language programs may use a CALL statement to invoke a subroutine, an interrupt instruction is needed to access the operating system routines.

When a program issues a subroutine call, the address of that subroutine must be known, so the processor will be able to jump to the code there. With most programs, subroutine addresses are determined and assigned by LINK.EXE when it combines the various portions of your program into a single executable file. But this method can't be used with the DOS and BIOS routines, because their addresses are not known ahead of time. For example, if you compile a BASIC program on an IBM PC, it must also be able to be run on, say, a Tandy 1000 using a different version of DOS. Of course, it is impossible for LINK to know where the DOS and BIOS routines are located on the Tandy computer.

To solve this problem and allow a program to call a routine whose address is not known, a list of addresses is stored in a known place in low memory. This place is called the *interrupt vector table*. The first 1,024 bytes in every PC contains a table of addresses for all 256 possible interrupts. Each table entry requires two words (four bytes): one word is used to hold the routine's segment, and the other holds its address within that segment. Whenever an assembly language program issues an interrupt instruction, the PC's processor automatically fetches the segment and address from this table, and then calls that address. Thus, any program may access any interrupt routine, without having to know where in memory the routine actually resides. The first four bytes in the interrupt vector table hold the address for Interrupt 0, the next four show where Interrupt 1 is, and so forth.

DOS and BIOS services are specified by interrupt number, and most interrupt routines also expect a *service number*. Nearly all of the DOS services you will find useful are accessed through Interrupt &H21, with the desired service number specified in the AH register. In many cases, information is also returned in the CPU registers. For instance, the DOS service that returns the current default disk drive is specified by placing the value &H19 in the AH register. When the interrupt has finished, the current drive number is returned in the AL register. Registers will be described in the section that follows. As with the low memory addresses discussed in Chapter 10, the DOS and BIOS interrupt numbers use Hexadecimal numbering by convention.

There are also several BIOS interrupts you will find useful, and these include video interrupt &H10, printer interrupt &H17, Print Screen interrupt 5, and the two equipment interrupts &H11 and &H12. There are other BIOS and DOS interrupts, but those are mostly useful when accessed from assembly language. For example, there is little need to call keyboard interrupt &H16 to read a key, since INKEY$ already does this. Likewise, you are unlikely to find disk interrupt &H13 very interesting, although it is used when performing copy protection and other low-level direct disk accesses. But unless you know what you are doing, it is possible--even likely--to trash your hard disk in the process of experimenting with this disk interrupt.

I won't attempt to provide all of the information you need to access every possible DOS and BIOS service here. Indeed, a complete discussion would fill several books. Two excellent books that I recommend are "Peter Norton's Programmer's Guide to the IBM PC" (1988), and "Advanced MS-DOS", by Ray Duncan (1988). Both of these books are published by Microsoft Press, and can be found in most book stores. These books list every DOS and BIOS interrupt service, and show which registers are used to exchange information with each interrupt service.

Also, once you have read and understood the information in this chapter you should go back to some of the examples presented in earlier chapters. In particular, Chapter 6 shows how to access DOS Interrupt &H21 to read file names, and Chapter 7 includes routines that access Interrupt &H2F to see if a network is running on the host PC and if so which one.

Registers

Microprocessors in the Intel 8086 family contain a set of built-in integer variables called *registers*. Each register can hold a single word (two bytes), which nicely corresponds to the size of a BASIC integer variable. Because these registers are contained within the microprocessor itself, they can be accessed by the CPU very quickly--much faster than variables which are stored in memory.

The 8086 and 8088 microprocessors contain a total of fourteen registers. [Newer CPUs contain more registers, but they are not accessible via CALL Interrupt nor are they useful to a BASIC program.] Some of these registers are intended for a specific use, while others may be used as general purpose variables. For example, the CS and DS registers contain the current code and data segments respectively, while the CX register is often used as a counter in an assembly language FOR/NEXT loop. I'm not going to pursue a lengthy discussion of microprocessor theory here though, because it's not really necessary if you simply want to access a few system interrupts. Rather, I will focus on how to set up and invoke the various interrupt services, and interpret the results they return. Assembly language and CPU registers will be discussed more fully in Chapter 12.

Both Interrupt and InterruptX (Interrupt Extended) require a TYPE variable with components that mirror each of the processor's registers. Figure 11-1 lists all of the 8086 registers that are accessible from BASIC, showing which are available with each of the interrupt routines.


InterruptX     Interrupt
==========     =========
  AX             AX
  BX             BX
  CX             CX
  DX             DX
  BP             BP
  SI             SI
  DI             DI
  Flags          Flags
  DS
  ES
Figure 11-1: The registers accessible from BASIC through Interrupt and InterruptX.

When you call the either Interrupt routine, the values in a TYPE variable are copied into the CPU's registers, the interrupt is performed, and then the results returned in each register are copied back into a TYPE variable again. All of the CALL Interrupt examples Microsoft shows use two TYPE variables called InRegs and OutRegs. However, you can also use the same TYPE variable to both send and receive the register values. In fact, using a single TYPE variable will save a few bytes of DGROUP memory. Therefore, the remaining examples that use CALL Interrupt use a single TYPE variable.

One important issue that needs to be addressed before we can proceed is how the CPU registers are accessed. I stated earlier that there are fourteen such registers, and each is the same size as an integer variable: 2 bytes. While this is certainly true, there is more to the story. Four of the registers--AX, BX, CX, and DX--can also be treated as being two separate one-byte registers.

Each register half uses the designator "H" or "L" to mean High or Low. For example, the high-byte portion of AX is called AH, and the low-byte portion of CX is CL. When considered as a composite register, the two halves form a single integer word. Figure 11-2 shows how the AX register is constructed, with each half contributing to the total combined value.


<  AX  >
ͻ
 1  1  0  1  0  0  0  1  1  1  0  0  1  1  0  1 
ͼ
< AH >< AL >
Figure 11-2: How a single word-sized register may also be treated as two byte-sized registers.

In an assembly language program it is simple to access each register half separately. However, BASIC does not offer a byte-sized variable type to use within the TYPE declaration. Therefore, a slight amount of math is required to get at each half separately. Although a fixed-length string with a length of one character could be used, the added overhead BASIC imposes to access a string as a number reduces the usefulness of that approach.

Using Hexadecimal notation and multiplication simplifies access to each register half when it is being assigned, and integer division and BASIC's AND operator lets you separate the two halves when reading them. That is, you can assign the value &H12 to the upper byte in AH and the value &h44 to the lower byte in AL at one time, like this:

     Registers.AX = &H1234

In many cases it is necessary to assign only AH, which can be done like this:

     Registers.AX = &H0600

Here, the value 6 is placed into AH, and 0 is assigned to AL. Since many of the DOS and BIOS services ignore what is in AL, assigning a value of zero is the simplest and most effective solution. Again, using Hexadecimal notation lets you clearly define what is in each register half, because the first two digits represent the upper portion, and the second two represent the lower byte.

When both the upper and lower bytes are important, you can use multiplication to assign them. By definition, any byte value in the high portion of a register is 256 times greater than it would be in the lower part. Thus, to assign the variable Low% to AL and High% to AH is as simple as this:

     Registers.AX = Low% + (256 * High%)

In practice the parentheses are not really necessary because multiplication is always performed before addition. But I included them here for clarity.

When an interrupt routine returns information in one of the combination registers, you may easily isolate the high and low portions as follows:

Low% = Registers.DX AND 255 High% = Registers.DX \ 256

Some examples you may have seen use MOD to extract the lower byte, and that will also work:

     Low% = Registers.DX MOD 256

Although MOD and AND cause BASIC to generate the same amount of assembly language code (three bytes), I generally prefer using AND because that instruction is somewhat faster on the older 8088 processors.

Accessing the BIOS

The simplest BIOS interrupt to call is the Print Screen interrupt, Interrupt 5. No parameters are required by this interrupt, and no values are returned when it finishes. But since the Interrupt routine expects the TYPE variable to be present and copies data to it, you must still dimension it in your program.

Because Interrupt and InterruptX are external subroutines as opposed to built-in commands, you will need to load the Quick Library containing these routines. QuickBASIC comes with the file QB.QLB; BASIC PDS provides the same routines in a library named QBX.QLB. [And in VB/DOS this file is called VBDOS.QLB.] You must of course use whichever is appropriate for your version of BASIC. To start QuickBASIC and load the Quick Library that contains these routines use the /L switch like this:

     qb /l

Normally, the name of a Quick Library must be given after the /L switch. However, QB and QBX know that /L by itself means to load the default QB.QLB or QBX.QLB Quick Library.

The following complete program prints a simple pattern on the screen, and then sends it to the printer designated as LPT1: as if the PrtSc key had been pressed.

DEFINT A-Z
TYPE RegType
  AX AS INTEGER
  BX AS INTEGER
  CX AS INTEGER
  DX AS INTEGER
  BP AS INTEGER
  SI AS INTEGER
  DI AS INTEGER
  Flags AS INTEGER
END TYPE
DIM Registers AS RegType

CLS
FOR X% = 1 TO 24
  PRINT STRING$(80, X% + 64);
NEXT
CALL Interrupt(5, Registers, Registers)

Although the Registers TYPE definition is shown here, the remaining examples in this chapter will instead specify the REGTYPE.BI include file that contains this code. QuickBASIC includes a similar include file called QB.BI, and BASIC PDS uses the name QBX.BI for the same file. [I created REGTYPE.BI so all of the programs in this book will run as is with any version of BASIC. But the BASIC-supplied versions also include DECLARE statements for the Interrupt routines, where my REGTYPE.BI file does not. Since all of these programs use the CALL keyword, a declaration is not strictly necessary.]

The BIOS Video Interrupt

The next example shows how to call BIOS video interrupt &H10 to clear just a portion of the display screen. It is designed as a combination demonstration and subprogram, so you can extract just the subprogram and add it to programs of your own.

DEFINT A-Z
DECLARE SUB ClearScreen (ULRow, ULCol, LRRow, LRCol, Colr)

'$INCLUDE: 'REGTYPE.BI'
DIM SHARED Registers AS RegType

CLS
FG = 7: BG = 1           'set the foreground and background colors
COLOR FG, BG

FOR X% = 1 TO 24
  PRINT STRING$(80, X% + 64);
NEXT

Colr = FG + 16 * BG      'use the same colors for clearing
CALL ClearScreen(5, 10, 20, 70, Colr)

SUB ClearScreen (ULRow, ULCol, LRRow, LRCol, Colr) STATIC
  Registers.AX = &H600
  Registers.BX = Colr * 256
  Registers.CX = (ULCol - 1) + (256 * (ULRow - 1))
  Registers.DX = (LRCol - 1) + (256 * (LRRow - 1))
  CALL Interrupt(&H10, Registers, Registers)
END SUB

There are two important benefits to using the BIOS for a routine such as this. One is of course the reduced amount of code that is needed, when compared to manually looping through memory using POKE to clear each character position. The second is the BIOS is responsible for determining the type of monitor installed, to select the correct video segment.

The demonstration portion of the program first clears the screen, and then creates a simple test pattern using a color of white on blue. Just before the call to ClearScreen, the correct Colr parameter is calculated based on the same foreground and background specified to BASIC. Where BASIC accepts separate foreground and background values, the BIOS requires a single composite color byte.

The simplified formula used in this example will accommodate normal colors, but does not support adding 16 to the foreground to specify a flashing color. This next formula shows how to derive a single color byte while also honoring flashing:

     Colr = (FG AND 16) * 8 + ((BG AND 7) * 16) + (FG AND 15)

ClearScreen is then called telling it to clear a rectangular portion of the screen that lies within the boundary specified by an upper-left corner at location 5, 10 to the lower-right corner at location 20, 70. The color value calculated earlier is also passed, so the white on blue color will be maintained even after the screen is cleared.

Within ClearScreen, four of the CPU's registers are assigned to values needed by the BIOS video interrupt. The first statement specifies service 6 in AH, which tells the BIOS to scroll the screen. The number of rows to scroll is then placed into the AL register, which we've set to zero. This particular BIOS service recognizes zero as a special flag, which tells it to clear the screen rather than scroll it.

Service 6 also expects the color to use for clearing in the BH register. As I explained earlier, multiplying by 256 is equivalent to assigning just the higher portion of an integer, so the statement Registers.BX = Colr * 256 is equivalent to placing the one byte that is actually used by the Colr variable into BH.

The next two instructions take the upper left and lower right corner arguments, and place them into the appropriate registers. In this case, the upper left column is placed into CL and the upper left row in CH. Similarly, the lower right column goes into DL and the lower right row into DH. Even though BASIC considers screen rows and columns to be numbered beginning at 1, the BIOS routines assume these to be zero-based.

Therefore, 1 is subtracted from the parameters before they are placed into each component of the Registers TYPE variable. Finally, BASIC's Interrupt routine is called specifying Interrupt number &H10.

Note that the same BIOS interrupt service can also be used to scroll a rectangular portion of the screen. Indeed, this is the primary purpose of service 6. To scroll a portion of the screen up a certain number of lines, you will place the number of lines into AL:

     Registers.AX = NumLines + (6 * 256)

Scrolling the screen downward is also possible, using service 7 like this:

     Registers.AX = NumLines + (7 * 256)

Also note that the Registers TYPE variable was dimensioned to be shared. This allows it to be accessed from all of the subprograms in a single program. If Registers is dimensioned in many different subprograms and functions, then a new instance will be created, with each stealing 20 bytes of DGROUP memory. Beware, however, that this memory savings has the potential drawback of introducing subtle bugs due to the same variable being used by different services. Whatever register values remain after one use of CALL Interrupt will still be present the next time, unless new values are explicitly assigned. [But that is rarely a problem, since you will generally assign all of the registers that a given interrupt needs just before calling that interrupt.]

Although this short example simply clears or scrolls a portion of the display screen, it provides a foundation for nearly anything else you may need to do using CALL Interrupt. The DOS interrupt examples that follow will build on this foundation, and show how to access a wealth of useful services that are not otherwise possible using BASIC alone.

Accessing DOS Interrupts

As with the BIOS video interrupt services, DOS interrupt &H21 expects a service number to be given in the AH register. Many DOS services require additional information in other registers as well, including integer values and the segments and addresses of variables.

The DOS services that accept or return a string (such as a file or directory name) require the address of the string, to know where it is located. For example, the DOS service that changes the current directory is called with AH set to &h4B, and DS:DX holding the address of a string that contains the name of the directory to change to.

Likewise, to obtain the current directory you would load AH with the value &H47, and DS:SI with the address of a string that will receive the current directory's name. It is essential that this string already be initialized to a sufficient length before calling DOS. Otherwise, the returned directory name will likely overwrite other existing data. [And if that data happens to be a BASIC string descriptor or back pointer you will likely crash the program and possibly even have to reboot the PC.]

When a string is sent as a parameter to a DOS routine, it must be terminated with a CHR$(0), so DOS can tell where it ends. Likewise, when DOS returns a string to your program such as the current directory, it indicates the end with a CHR$(0). Therefore, it is up to your program to manually append a CHR$(0) to any file or directory names you pass to DOS. And when receiving a string from DOS, you must use INSTR to locate the CHR$(0) that marks the end, and keep only what precedes that character.

I will start with some simple examples that access DOS Interrupt &H21, and proceed to more complex routines that pass and receive string data.

Accessing the Default Drive

The first DOS example shows how to determine the current default drive, and it is designed as a DEF FN-style function. A function is a natural way to design a routine that returns information, as opposed to a called subprogram. Further, using a DEF FN-style function reduces the amount of code that BASIC generates, and also reduces the code needed each time the function is invoked.

DEFINT A-Z

'$INCLUDE: 'REGTYPE.BI'
DIM Registers AS RegType

DEF FnGetDrive%
  Registers.AX = &H1900
  CALL Interrupt(&H21, Registers, Registers)
  FnGetDrive% = (Registers.AX AND &HFF) + 65
END DEF

PRINT "The current default drive is "; CHR$(FnGetDrive%)

Here, service number &H19 is assigned to the AH portion of AX prior to calling Interrupt &H21, and the value that DOS returns in AL indicates the current drive. For this service DOS uses 0 to indicate drive A, 1 for drive B, and so forth. Therefore, you use AND with the value &HFF (255) to keep just the low portion in AX. Once the DOS drive number has been isolated, the program adds 65 to adjust that to the equivalent ASCII character value.

Setting a new default drive is just as easy as obtaining the current drive. Although BASIC PDS provides the CHDRIVE command to set a new drive as the current default, QuickBASIC does not. The ChDrive subprogram that follows affords the same functionality to QuickBASIC users, and it accepts a single letter to indicate which drive is to be made the new current default.

DEFINT A-Z
DECLARE SUB ChDrive (Drive$)

'$INCLUDE: 'REGTYPE.BI'

DIM SHARED Registers AS RegType

INPUT "Enter the drive to make current: ", NewDrive$
CALL ChDrive(NewDrive$)

SUB ChDrive (Drive$) STATIC
  Registers.AX = &HE00
  Registers.DX = ASC(UCASE$(Drive$)) - 65
  CALL Interrupt(&H21, Registers, Registers)
END SUB

Now that you know how to set and get the current default drive, you can combine the two and create a function that tells if a given drive letter is valid. Many DOS services return the success or failure of an operation using the CPU's Carry flag. However, the service that sets a new drive is a notable exception. Therefore, to determine if a given drive letter is in fact valid requires more than simply trying to set the new drive, and then seeing if an error resulted.

The only way to tell if a request to change the current drive was accepted is to make another call to get the current drive, thereby seeing if the original request took effect. The program that follows accepts a drive letter as a string, and returns True or False (-1 or 0) to indicate whether or not the drive is valid.

DEFINT A-Z
DECLARE SUB ChDrive (Drive$)

'$INCLUDE: 'REGTYPE.BI'

DIM SHARED Registers AS RegType

DEF FnGetDrive%
  Registers.AX = &H1900
  CALL Interrupt(&H21, Registers, Registers)
  FnGetDrive% = (Registers.AX AND &HFF) + 65
END DEF

DEF FnDriveValid% (TestDrive$)
  STATIC Current                'local to this function
  Current = FnGetDrive%         'save the current drive
  FnDriveValid% = 0             'assume not valid
  CALL ChDrive(TestDrive$)      'try to set a new drive
  IF ASC(UCASE$(TestDrive$)) = FnGetDrive% THEN
     FnDriveValid% = -1         'they match so it's valid
  END IF
  CALL ChDrive(CHR$(Current))   'either way restore it
END DEF

INPUT "Enter the drive to test for validity: ", Drive$
IF FnDriveValid%(Drive$) THEN
   PRINT Drive$; " is a valid drive."
ELSE
   PRINT "Sorry, drive "; Drive$; " is not valid."
END IF

SUB ChDrive (Drive$) STATIC
  Registers.AX = &HE00
  Registers.DX = ASC(UCASE$(Drive$)) - 65
  CALL Interrupt(&H21, Registers, Registers)
END SUB

The strategy used here is to first save the current default drive, and then set a new drive on a trial basis. If the current drive is the one that was just set, then the specified drive was indeed valid. In either case, the original drive must be restored.

Determining if a File Exists

Both of the DOS services we have considered so far use integer arguments to indicate the new drive, or which drive is the current default. The next example shows how to pass a BASIC string to a DOS service, which is somewhat more complicated. The situation is made worse by the far strings feature available in BASIC PDS. Therefore, be sure to observe the comment that shows how to replace SSEG with VARSEG for use with QuickBASIC.

Chapter 6 showed an admittedly clunky way to determine if a file is present. The example given there attempted to open the specified file for random access, and then used LOF to see if the file had a length of zero. The problem with that method--besides requiring a lot of unnecessary DOS activity--is that it reports a file with a perfectly legal length of zero as not being present, and then deletes it!

The FnFileExist function that follows is intended for use with BASIC PDS, and comments show how to change it for use with QuickBASIC. Please understand that PDS doesn't really need a File Exist function, since DIR$ can be used for that purpose. The statement IF LEN(DIR$(FileSpec$)) THEN will quickly tell if a file is present. However, the point is to show how strings are passed to DOS, and for that purpose this example serves quite nicely.

DEFINT A-Z
'$INCLUDE: 'REGTYPE.BI'

DIM Registers AS RegType

TYPE DTA                         'used by DOS services
  Reserved  AS STRING * 21       'reserved for use by DOS
  Attribute AS STRING * 1        'the file's attribute
  FileTime  AS STRING * 2        'the file's time
  FileDate  AS STRING * 2        'the file's date
  FileSize  AS LONG              'the file's size
  FileName  AS STRING * 13       'the file's name
END TYPE
DIM DTAData AS DTA

DEF FnFileExist% (Spec$)
  FnFileExist% = -1              'assume the file exists

  Registers.DX = VARPTR(DTAData) 'set a new DOS DTA
  Registers.DS = VARSEG(DTAData)
  Registers.AX = &H1A00
  CALL InterruptX(&H21, Registers, Registers)

  Spec$ = Spec$ + CHR$(0)      'DOS needs an ASCIIZ string
  Registers.AX = &H4E00        'find file name service
  Registers.CX = 39            'attribute for any file
  Registers.DX = SADD(Spec$)   'show where the spec is
  Registers.DS = SSEG(Spec$)   'use this with BASIC PDS
 'Registers.DS = VARSEG(Spec$) 'use this with QuickBASIC

  CALL InterruptX(&H21, Registers, Registers)
  IF Registers.Flags AND 1 THEN FnFileExist% = 0
END DEF

INPUT "Enter a file name or specification: ", FileSpec$
IF FnFileExist%(FileSpec$) THEN
   PRINT FileSpec$; " does exist"
ELSE
   PRINT "Sorry, no files match "; FileSpec$
END IF

FnFileExist calls upon the DOS Find First service that searches a directory and attempts to locate the first file that matches a given specification template. Therefore, besides being able to see if ACCOUNTS.DAT or F:\UTILS\NU.EXE exist, you can also use the DOS wild cards. For example, given C:\QB45\*.BAS, FnFileExist will report if any files with a .BAS extension are in the \QB45 directory of drive C.

As part of its directory searching mechanism, DOS requires a block of memory known as a Disk Transfer Area, or DTA for short. If a matching file name is found, DOS stores important information about the file there, where your program can read it. As you can see by examining the DTAType structure, this includes the file's name and extension, the date and time it was last written, to, its current size, and attribute. The 21-byte string at the beginning identified as Reserved holds sector numbers and other information, and is used by DOS for subsequent searches. This function doesn't use any of the information in the DTA; however, it must still be defined for use by DOS.

You will notice that FnFileExist uses the InterruptX routine rather than Interrupt, and this is to provide support for use with BASIC PDS far strings. Two of the CPU's registers are used to hold the DS and ES data segment registers. When Interrupt is called, it simply leaves whatever is currently in DS and ES and then calls the interrupt. InterruptX, on the other hand, loads DS and ES from those components of the Registers TYPE variable, and those are the values the interrupt itself receives. Were FnFileExist limited to working with QuickBASIC [where all strings are in the DS segment], Interrupt would be sufficient and the added complication of using either VARSEG or SSEG could be avoided.

Note that InterruptX can also be told to use the current value of DS for both DS and ES, when the calling program doesn't need or want to change them. This is specified by placing a value of -1 into either or both portions of the Registers TYPE variable. For example, the statement Registers.DS = -1 tells InterruptX not to assign DS before performing the interrupt. Otherwise, if Registers.DS were not assigned, DS would receive the value 0 which is incorrect for DOS services that receive a variable's address. In a similar manner, Registers.ES = -1 tells InterruptX to set ES to the current value of DS.

The Carry Flag

The last item to note in this function is how the Carry flag is tested. As I mentioned earlier, many DOS services indicate the success or failure of an operation by either clearing or setting the CPU's Carry flag. This flag is held in one bit in the Flags register, and its primary purpose is to assist multi-word arithmetic in assembly language programs. But because the 80x86 provides single instructions that easily set and test this flag, the designers of DOS decided to use it as an error indicator.

The Carry flag is stored in the lowest bit of the Flags register, and can therefore be tested using the AND instruction with a value of 1. If that bit is set, the result of the AND test will be one; otherwise it will be zero. Thus, the statement IF Registers.Flags AND 1 THEN will be true if the Carry flag is set, which indicates an error. In the case of DOS' Find First function this is not really an error in the strictest sense. But there is no need here to distinguish between, say, an invalid path name and the lack of any matching files. Either a match was found or it wasn't.

Improving on Interrupt

Recall that Chapter 8 introduced the DOSInt routine which serves as a small-code replacement for BASIC's InterruptX routine. Although the reduction in code size gained by using DOSInt versus Interrupt or InterruptX is not dramatic, it can save several hundred bytes in a program that calls it many times. DOSInt is also somewhat easier to set up and use, because it requires only a single Registers argument.

Of course, DOSInt is meant only for use with DOS Interrupt &H21, and it will not work with any other DOS or BIOS interrupt services. Because of the savings that DOSInt affords, the remaining DOS examples in this chapter will use DOSInt instead of Interrupt or InterruptX. Like InterruptX, DOSInt lets you access the DS and ES registers, and it also recognizes an incoming value of -1 to specify the current contents of DS.

Obtaining the Current Directory

Where FnFileExist shows how to pass a BASIC string to a DOS interrupt service, the FnGetDir function following shows how to receive a string from DOS. Again, BASIC PDS users have the CURDIR$ function which reports the current directory, but most QuickBASIC programmers will find this function invaluable.

DEFINT A-Z
'$INCLUDE: 'REGTYPE.BI'

DIM Registers AS RegType

DEF FnGetDir$ (Drive$)
  STATIC Temp$, Drive, Zero     'local variables

  IF LEN(Drive$) THEN           'did they pass a drive?
    Drive = ASC(UCASE$(Drive$)) - 64
  ELSE
    Drive = 0
  END IF

  Temp$ = SPACE$(65)            'DOS stores the name here

  Registers.AX = &H4700         'get directory service
  Registers.DX = Drive          'the drive goes in DL
  Registers.SI = SADD(Temp$)    'show DOS where Temp$ is
  Registers.DS = SSEG(Temp$)    'use this with BASIC PDS
 'Registers.DS = -1             'use this with QuickBASIC

  CALL DOSInt(Registers)        'call DOS

  IF Registers.Flags AND 1 THEN 'must be an invalid drive
    FnGetDir$ = ""
  ELSE
    Zero = INSTR(Temp$, CHR$(0))    'find the zero byte
    FnGetDir$ = "\" + LEFT$(Temp$, Zero)
  END IF
END DEF

PRINT "Which drive? ";
DO
  Drive$ = INKEY$
LOOP UNTIL LEN(Drive$)
PRINT

Cur$ = FnGetDir$(Drive$)
IF LEN(Cur$) THEN
  PRINT "The current directory is ";
  PRINT Drive$; ":"; FnGetDir$(Drive$)
ELSE
  PRINT "Invalid drive"
END IF

PRINT "The current directory for the default drive is ";
PRINT FnGetDir$("")

The variables Temp$, Drive, and Zero are declared as STATIC to prevent them from conflicting with variables of the same name in your program. Of course, you could convert this to a formal FUNCTION procedure if you prefer, which considers variables local by default. Converting to a formal function is also needed if you plan to access it from multiple source modules.

Unlike the DOS Get Drive and Set Drive services, service &H47 uses a value of one to indicate drive A, 2 for drive B, and so forth. To request the current directory on the default drive you must use a value of zero. An explicit test for this is made at the beginning of the function. Later, this value is assigned to Registers.DX where DOS expects it. Note that it is really DL that will hold the specified drive number. But assigning DX from Drive as shown does this, and also clears the high (DH) portion in the process. Since the contents of DH are ignored by this DOS service, no harm is done and the extra code that would be needed to assign only DL can be avoided.

As I mentioned earlier, it is essential that you set aside space to hold the returned directory name. Since the longest path name that DOS can accommodate is 65 characters, Temp$ is assigned to that length. Then, the segment and address where Temp$ is stored are passed to DOS in the DS and SI registers. Note that DOS is not very consistent in its use of registers. Where the service that finds the first matching file name uses DS:DX to point to the file specification, this service uses DS:SI to point to the string.

Like the FnFileExist function, you must change the statement that assigns Registers.DS if you plan to use this one with QuickBASIC. The BASIC PDS version of that statement is left active rather than the QuickBASIC version, so QuickBASIC will highlight that line as an error to remind you. Although FnFileExist uses VARSEG for the DS value when used with QuickBASIC, FnGetDir uses -1. Both methods work, and I used -1 here just to show that in context.

After DOSInt is called to load Temp$ with the current directory name, the Carry Flag is tested to see if an error occurred. The only error that is possible here is "Invalid drive", in which case FnGetDir$ is assigned a null value as a flag to indicate that. Otherwise, INSTR is used to locate the CHR$(0) zero byte that DOS assigned to mark the end of the name.

This error testing can be left out to save code if you prefer. You could also validate the drive using the FnDriveValid function, either by adding the code within FnGetDir, or separately prior to invoking it.

Reading File and Directory Names

One important service that many programs need and which BASIC has never provided is the ability to read directory names from disk. Any word processor worth its salt will let you view a list of files that match, say, a *.DOC extension, and then select the one you want to edit. With the introduction of BASIC PDS Microsoft added the DIR$ function, which lets you read file names. However, there is no way to specify file attributes (hidden, read-only, and so forth), and also no way to read directory names. To add insult to injury, the PDS manuals do not show clearly how to read a list of file names, and store them into a string array.

The program that follows counts the number of files or directories that match a given specification, and then dimensions and loads a string array with their names.

DEFINT A-Z
DECLARE SUB LoadNames (FileSpec$, Array$(), Attribute%)

'$INCLUDE: 'REGTYPE.BI'

TYPE DTA                        'used by find first/next
  Reserved  AS STRING * 21      'reserved for use by DOS
  Attribute AS STRING * 1       'the file's attribute
  FileTime  AS STRING * 2       'the file's time
  FileDate  AS STRING * 2       'the file's date
  FileSize  AS LONG             'the file's size
  FileName  AS STRING * 13      'the file's name
END TYPE

DIM SHARED DTAData AS DTA       'shared so LoadNames can
DIM SHARED Registers AS RegType '  access them too


DEF FnFileCount% (Spec$, Attribute)
  STATIC Count                   'make this private

  Registers.DX = VARPTR(DTAData) 'set new DTA address
  Registers.DS = -1              'the DTA is in DGROUP
  Registers.AX = &H1A00          'specify service 1Ah
  CALL DOSInt(Registers)         'DOS set DTA service

  Count = 0                      'clear the counter
  Spec$ = Spec$ + CHR$(0)        'make an ASCIIZ string
  IF Attribute AND 16 THEN       'find directory names?
    DirFlag = -1                 'yes
  ELSE
    DirFlag = 0                  'no
  END IF

  Registers.DX = SADD(Spec$)     'the file spec address
  Registers.DS = SSEG(Spec$)     'this is for BASIC PDS
 'Registers.DS = -1              'this is for QuickBASIC
  Registers.CX = Attribute       'assign the attribute
  Registers.AX = &H4E00          'find first matching name

  DO
    CALL DOSInt(Registers)       'see if there's a match
    IF Registers.Flags AND 1 THEN EXIT DO   'no more
    IF DirFlag THEN
      IF ASC(DTAData.Attribute) AND 16 THEN
        IF LEFT$(DTAData.FileName, 1) <> "." THEN
          Count = Count + 1      'increment the counter
        END IF
      END IF
    ELSE
      Count = Count + 1          'they want regular files
    END IF

    Registers.AX = &H4F00        'find next name
  LOOP

  FnFileCount% = Count           'assign the function
END DEF


REDIM Names$(1 TO 1)             'create a dynamic array
Attribute = 19                   'matches directories only
Attribute = 39                   'matches all files

INPUT "Enter a file specification: ", Spec$
CALL LoadNames(Spec$, Names$(), Attribute)

FOR X = LEN(Spec$) TO 1 STEP -1  'isolate the drive/path
  Temp = ASC(MID$(Spec$, X, 1))
  IF Temp = 58 OR Temp = 92 THEN '":" or "\"
    Path$ = LEFT$(Spec$, X)      'keep what precedes that
    EXIT FOR                     'and we're all done
  END IF
NEXT

FOR X = 1 TO UBOUND(Names$)      'print the names
  PRINT Path$; Names$(X)
NEXT

PRINT
PRINT UBOUND(Names$); "matching file(s)"
END


SUB LoadNames (FileSpec$, Array$(), Attribute) STATIC

  Spec$ = FileSpec$ + CHR$(0)     'make an ASCIIZ string
  NumFiles = FnFileCount%(Spec$, Attribute) 'count names
  IF NumFiles = 0 THEN EXIT SUB             'exit if none
  REDIM Array$(1 TO NumFiles)    'dimension the array

  IF Attribute AND 16 THEN       'find directory names?
    DirFlag = -1                 'yes
  ELSE
    DirFlag = 0                  'no
  END IF

  '---- The following code isn't strictly necessary
  '     because we know that FnFileCount already set the
  '     DTA address.
 'Registers.DX = VARPTR(DTAData) 'set new DTA address
 'Registers.DS = -1              'the DTA in DGROUP
 'Registers.AX = &H1A00          'specify service 1Ah
 'CALL DOSInt(Registers)         'DOS set DTA service

  Registers.DX = SADD(Spec$)     'the file spec address
  Registers.DS = SSEG(Spec$)     'this is for BASIC PDS
 'Registers.DS = -1              'this is for QuickBASIC
  Registers.CX = Attribute       'assign the attribute
  Registers.AX = &H4E00          'find first matching name
  Count = 0                      'clear the counter

  DO
    CALL DOSInt(Registers)       'see if there's a match
    IF Registers.Flags AND 1 THEN EXIT DO   'no more
    Valid = 0
    IF DirFlag THEN                         'directories?
      IF ASC(DTAData.Attribute) AND 16 THEN
        IF LEFT$(DTAData.FileName, 1) <> "." THEN
          Valid = -1             'this name is valid
        END IF
      END IF
    ELSE
      Valid = -1                 'they want regular files
    END IF

    IF Valid THEN                'process the file if it
      Count = Count + 1          '  passed all the tests
      Zero = INSTR(DTAData.FileName, CHR$(0))
      Array$(Count) = LEFT$(DTAData.FileName, Zero - 1)
    END IF
    Registers.AX = &H4F00        'find next matching name
  LOOP

END SUB

These routines call upon the DOS Find First and Find Next services, which performs the actual searching and loading of the names. Before the names can be loaded into an array, you need some way to know how many files there are. Therefore, the FnFileCount function makes repeated calls to DOS to find another file, until there are no more.

The general strategy is to request service &H4E to find the first matching file. If a file is found then the Carry Flag is returned clear; otherwise it is set and the function returns with a count of zero. If a file is found Registers.AX is assigned a value of &H4F, and this tells DOS to resume searching based on the same file specification as before. Where the FnFileExist function merely needed to check for the presence of a file using the Find First service, this one continues in a DO loop until no more matching files are found.

Understand that these DOS services accept either a partial file specification such as "*.BAS" or "D:\PATHNAME\*.*", or a single file name such as "CONFIG.SYS" or "C:\AUTOEXEC.BAT".

File Attributes

The DOS Find services also accept--and require--a file attribute indicating the type of files that are being sought. The method of specifying and isolating files and their attributes is convoluted and confusing to be sure. Figure 11-3 lists each of the six file attributes, and shows which corresponds to each bit in the attribute byte.


 7   6   5   4   3   2   1   0  < Bits
128  64  32  16  8   4   2   1  < Numeric Values
       
                      
                       Read-Only
                    Hidden
                 System
              Volume Label
           Subdirectory
        Archive
  Unused
Figure 11-3: The makeup of the bits in the attribute byte, and the individual decimal value of each.

In most cases, the attribute bits are cumulative. For example, if you specify that you want to locate files marked as read-only, you will also get files that are not. But if you leave that bit clear, then read-only files will not be included. The same logic is used for reading directory names. If the directory bit is set then you will read directories, and also regular files whose directory bit is not set. This requires that you perform additional qualifications when the file name is read into the DTA. To make matters even worse, there is an exception to this rule whereby an attribute of zero will still read file names whose archive bit is set.

Before considering how to qualify the names as they are read, you must first understand what attributes are and how to specify them to begin with. Every file has an attribute, which is set by DOS to Archive at the time it is created. The archive bit is used solely to tell if the file has been backed up using the DOS BACKUP utility. When BACKUP copies the file to a floppy disk, it clears the Archive bit in the file's directory entry. Then if the file is written to again later, DOS sets that bit. This way, BACKUP can tell which files need to be backed up, and which ones haven't changed since the last backup was performed. Most modern commercial backup utilities also manipulate the archive bit, for the same reason that DOS' BACKUP does.

The hidden bit tells the DOS DIR command not to display that file's name. Although it won't display in a directory listing, a hidden file may be opened, read from, and written to. The system bit is similar in that it also tells DIR not to display the file. The IO.SYS and MSDOS.SYS files that come with MS-DOS are hidden system files, so to read their names you must set those bits in the search attribute. Note that IBM's version of DOS uses the names IBMBIO.COM and IBMDOS.COM respectively for the same files.

The label bit identifies a file as the disk's volume label, which isn't really a file at all. Every disk is allowed to have one volume label entry in its root directory, which lets an application identify the disk. This feature is not particularly important with hard disks, but when floppy-only systems were the norm this let programs ensure that the correct data diskette was installed in the drive. Even though a volume label is stored in the disk's directory like a regular file name, no sectors are allocated to it. Note that a bug in DOS 2.x versions causes a search for a volume label to fail. The only work-around is to use the more complex DOS 1.x Find First/Next services that are still supported in later versions for compatibility with older programs.

Finally, the subdirectory attribute bit identifies a file as a directory. From DOS' perspective a subdirectory *is* a file, with fixed- length records that hold the names, attributes, and other information for the files it contains. Notice that the "." and ".." directory entries that appear when you type DIR are in fact present in that directory.

Every directory except the root contains these entries, and they also have a directory attribute. The single dot refers to the current directory, and the double dots to the parent directory one level above. I mention this because these "dot" entries are reported by the Find First and Find Next services, and in many cases you will want to filter them out.

To specify a file attribute you must determine the correct value, based on the individual bits to be included in the search. As I stated earlier, setting the attribute to zero includes all normal files, and exclude any marked as read-only, hidden, system, or subdirectory. Therefore, to include all files but not subdirectories you will use an attribute value of 39. This value is derived by adding up the bit values for each desired attribute as shown in Figure 11-3.

When you add all of the values for each bit of interest, the answer is 32 (archive) + 4 (system) + 2 (hidden) + 1 (read-only) = 39. In a similar fashion, you will use 16 to read directory names, but hidden or read-only directories will not be included unless you also add 2 + 1 = 3, resulting in a final value of 19.

Although you can specify attribute bits in nearly any combination, DOS returns all of the names that match any of the bits. Therefore, you must further qualify the files by examining the attribute DOS returns in the DTA TYPE variable. A typical search for directory names will ask to include all three attribute bits (directory, hidden, and read-only), but the qualification test merely tests if the directory bit is set. The following excerpt shows this in context.

     Registers.CX = 19
     CALL DOSInt(Registers)
     IF ASC(DTAData.Attribute) AND 16 THEN  'it is a directory

Even if the directory was in fact hidden or read-only, the test for the directory bit will succeed regardless of any other bits that may be set. Unfortunately, the reverse is not true. If the directory is not hidden or read-only, then testing for those bits will fail. Both the FnFileCount function and the LoadNames subprogram include an explicit test for directory searches, and contain additional logic to check for this case.

You could also add similar logic to the FnFileExist function, or create a separate version perhaps called FnDirExist that adds a test for the directory bit and also filters out the "dot" entries.

REDIM preserve

One glaring shortcoming you have probably already noticed is the enormous amount of code that is duplicated in both the FnFileCount and LoadNames routines. In fact, the two are almost identical, except that LoadNames also assigns elements in the array. Worse, having to count all of the names before they can be read greatly increases the amount of time needed to process a directory when there are many files. Until you know how many files are present, there's no way to known how large to dimension the string array.

One solution is to create an array with, say, 500 elements, and hope that the actual number of files does not exceed that. But if there are only a few files this wastes a lot of memory, and when there are more than 500, then, well, you're still out of luck. In fact, this is one of the few features that C offers but QuickBASIC does not. C programs can allocate memory that will be treated as an array, and then repeatedly request more memory for that same array as it is needed.

Fortunately, BASIC PDS version 7.1 includes the PRESERVE option to the REDIM statement. This allows you to increase (or decrease) the size of an array, but without destroying its current contents. Thus, REDIM PRESERVE is ideal for applications like this that require an array's size to be altered. The next, much shorter program uses REDIM PRESERVE to advantage, and avoids the extra step that counts how many files match the search specification. Of course, this program requires BASIC PDS.

DEFINT A-Z
DECLARE SUB LoadNames (FileSpec$, Array$(), Attribute%)

'$INCLUDE: 'REGTYPE.BI'

TYPE DTA                        'used by find first/next
  Reserved  AS STRING * 21      'reserved for use by DOS
  Attribute AS STRING * 1       'the file's attribute
  FileTime  AS STRING * 2       'the file's time
  FileDate  AS STRING * 2       'the file's date
  FileSize  AS LONG             'the file's size
  FileName  AS STRING * 13      'the file's name
END TYPE

DIM SHARED DTAData AS DTA       'shared so LoadNames can
DIM SHARED Registers AS RegType '  access them too

REDIM Names$(1 TO 1)             'create a dynamic array
Attribute = 19                   'matches directories only
Attribute = 39                   'matches all files
Spec$ = "*.*"                    'so does this
CALL LoadNames(Spec$, Names$(), Attribute)

IF Names$(1) = "" THEN           'check for no files
  PRINT "No matching files"
ELSE
  FOR X = 1 TO UBOUND(Names$)    'print the names
    PRINT Path$; Names$(X)
  NEXT
END IF
END


SUB LoadNames (FileSpec$, Array$(), Attribute) STATIC
  Spec$ = FileSpec$ + CHR$(0)    'make an ASCIIZ string
  Count = 0                      'clear the counter

  Registers.DX = VARPTR(DTAData) 'set new DTA address
  Registers.DS = -1              'the DTA is in DGROUP
  Registers.AX = &H1A00          'specify service 1Ah
  CALL DOSInt(Registers)         'DOS set DTA service

  IF Attribute AND 16 THEN       'find directory names?
    DirFlag = -1                 'yes
  ELSE
    DirFlag = 0                  'no
  END IF

  Registers.DX = SADD(Spec$)     'the file spec address
  Registers.DS = SSEG(Spec$)     'this is for BASIC PDS
  Registers.CX = Attribute       'assign the attribute
  Registers.AX = &H4E00          'find first matching name

  DO
    CALL DOSInt(Registers)       'see if there's a match
    IF Registers.Flags AND 1 THEN EXIT DO   'no more

    Valid = 0                    'invalid until qualified
    IF DirFlag THEN              'find directories?
      IF ASC(DTAData.Attribute) AND 16 THEN 'yes, is it?
        IF LEFT$(DTAData.FileName, 1) <> "." THEN
          Valid = -1             'this name is valid
        END IF
      END IF
    ELSE
      Valid = -1                 'they want regular files
    END IF

    IF Valid THEN                'process the file if it
      Count = Count + 1          '  passed all the tests
      REDIM PRESERVE Array$(1 TO Count)  'expand the array
      Zero = INSTR(DTAData.FileName, CHR$(0)) 'find zero
      Array$(Count) = LEFT$(DTAData.FileName, Zero - 1)
    END IF

    Registers.AX = &H4F00        'find next matching name
  LOOP
END SUB

Managing Files

Chapter 6 explained in great detail how files are opened, closed, read, and written using BASIC. I mentioned there that BASIC imposes a number of arbitrary limitations on what you can and cannot do with files. Indeed, DOS allows almost any action except writing to a file that has been opened for input. As you can imagine, CALL Interrupt--or in this case the DOSInt replacement routine--can be used to circumvent BASIC and access your files directly.

Although BASIC expects you to state how the file will be accessed with the various OPEN options, to DOS all files are considered as being opened for binary access. There is no equivalent DOS service for BASIC's INPUT # or PRINT # commands. Therefore, it is up to you to write subroutines that look for a terminating carriage return and optional line feed when reading sequential text. Likewise, it is up to you to manually append a carriage return and line feed to the end of each line of text written to disk.

Frankly, sequential file access is often best left to BASIC, since a lot of time-consuming tests are needed when reading sequential data. You could, however, use the BufIn function shown in Chapter 6, or similar logic of your own devising. There are many types of file access that can be performed using direct DOS calls, and I will show those that are the most useful and appropriate here.

The program that will follow shortly is a combination demonstration, and suite of twelve subprograms and functions that perform most of the services necessary for manipulating files. Subprograms are provided to replace BASIC's OPEN, CLOSE, GET, and PUT statements, as well as LOCK and UNLOCK, SEEK, and KILL.

There are also replacement functions for LOC and LOF, as well as two additional subprograms that have no BASIC equivalent. All of the routines use the DOSInt interface routine, and avoid using BASIC's file handling statements. The demonstration is comprised of a series of code blocks that exercise each routine showing how it is used. Comments at the start of each block explain what is being demonstrated.

One reason to go behind BASIC's back this way is to avoid its many restrictions. For example, BASIC will not let you read from a file that has been opened for output, even though DOS considers this to be perfectly legal. Another is to avoid the need for ON ERROR. As you learned in Chapter 3, ON ERROR can make a program run more slowly, and also increase its size. By going directly to DOS you can avoid the burden of ON ERROR, which is otherwise needed to prevent your program from terminating if an error occurs. These replacement routines avoid errors such as those caused by attempting to open a file that does not exist, or trying to lock a network file that has already been locked by someone else.

As with some of the other programs in this book that combine a demonstration and subroutines, you should make a copy of the file, and then delete all of the code in the main portion of the program. The only lines that must not be deleted are the DEFINT, DECLARE, and INCLUDE statements, and also the two DIM SHARED statements. Then, you can load the resultant module into the BASIC editor along with your own main application.

'DOS.BAS, demonstrates the direct DOS access routines

DEFINT A-Z
DECLARE FUNCTION DOSError% ()
DECLARE FUNCTION ErrMessage$ (ErrNumber)
DECLARE FUNCTION LocFile& (Handle)
DECLARE FUNCTION LofFile& (Handle)
DECLARE FUNCTION PeekWord% (BYVAL Segment, BYVAL Address)

DECLARE SUB ClipFile (Handle, NewLength&)
DECLARE SUB CloseFile (Handle)
DECLARE SUB FlushFile (Handle)
DECLARE SUB KillFile (FileName$)
DECLARE SUB LockFile (Handle, Location&, NumBytes&, Action)
DECLARE SUB OpenFile (FileName$, OpenMethod, Handle)
DECLARE SUB ReadFile (Handle, Segment, Address, NumBytes)
DECLARE SUB SeekFile (Handle, Location&, SeekMethod)
DECLARE SUB WriteFile (Handle, Segment, Address, NumBytes)


'$INCLUDE: 'REGTYPE.BI'

DIM SHARED Registers AS RegType 'so all can access it
DIM SHARED ErrCode              'ditto for the ErrCode
CRLF$ = CHR$(13) + CHR$(10)     'define this once now

COLOR 15, 1                     'this makes the DOS
CLS                             'messages high-intensity
COLOR 7, 1


'---- Open the test file we will use.
FileName$ = "C:\MYFILE.DAT"     'specify the file name
OpenMethod = 2                  'read/write non-shared
CALL OpenFile(FileName$, OpenMethod, Handle)
GOSUB HandleErr
PRINT FileName$; " successfully opened, handle:"; Handle


'---- Write a test message string to the file.
Msg$ = "This is a test message." + CRLF$
Segment = SSEG(Msg$)            'use this with BASIC PDS
'Segment = VARSEG(Msg$)         'use this with QuickBASIC
Address = SADD(Msg$)
NumBytes = LEN(Msg$)
CALL WriteFile(Handle, Segment, Address, NumBytes)
GOSUB HandleErr
PRINT "The test message was successfully written."


'---- Show how to write a numeric value.
IntData = 1234
Segment = VARSEG(IntData)
Address = VARPTR(IntData)
NumBytes = 2
CALL WriteFile(Handle, Segment, Address, NumBytes)
GOSUB HandleErr
PRINT "The integer variable was successfully written."


'---- See how large the file is now.
Length& = LofFile&(Handle)
GOSUB HandleErr
PRINT "The file is now"; Length&; "bytes long."


'---- Seek back to the beginning of the file.
Location& = 1                   'specify file offset 1
SeekMethod = 0                  'relative to beginning
CALL SeekFile(Handle, Location&, SeekMethod)
GOSUB HandleErr
PRINT "We successfully seeked back to the beginning."


'---- Ensure that the Seek worked by seeing where we are.
CurSeek& = LocFile&(Handle)
GOSUB HandleErr
PRINT "The DOS file pointer is now at location"; CurSeek&


'---- Read the test message back in again.
Buffer$ = SPACE$(23)            'the length of Msg$
Segment = SSEG(Buffer$)         'use this with BASIC PDS
'Segment = VARSEG(Buffer$)      'use this with QuickBASIC
Address = SADD(Buffer$)
NumBytes = LEN(Buffer$)
CALL ReadFile(Handle, Segment, Address, NumBytes)
GOSUB HandleErr
PRINT "Here is the test message: "; Buffer$


'---- Skip over the CRLF by reading it as an integer.
Address = VARPTR(Temp)          'read the CRLF into Temp
Segment = VARSEG(Temp)
NumBytes = 2
CALL ReadFile(Handle, Segment, Address, NumBytes)
GOSUB HandleErr


'---- Read the integer written earlier, also into Temp.
Address = VARPTR(Temp)
Segment = VARSEG(Temp)
NumBytes = 2
CALL ReadFile(Handle, Segment, Address, NumBytes)
GOSUB HandleErr
PRINT "The integer value just read is:"; Temp


'---- Append a new string at the end of the file.
Msg$ = "This is appended to the end of the file." + CRLF$
Segment = SSEG(Msg$)            'use this with BASIC PDS
'Segment = VARSEG(Msg$)         'use this with QuickBASIC
Address = SADD(Msg$)
NumBytes = LEN(Msg$)
CALL WriteFile(Handle, Segment, Address, NumBytes)
GOSUB HandleErr
PRINT "The appended message has been written, ";
PRINT "but it's still in the DOS file buffer."


'---- Flush the file's DOS buffer to disk.
CALL FlushFile(Handle)
GOSUB HandleErr
PRINT "Now the buffer has been flushed to disk.  ";
PRINT "Here's the file contents:"
SHELL "TYPE " + FileName$


'---- Display the current length of the file again.
PRINT "Before calling ClipFile the file is now";
Length& = LofFile&(Handle)
GOSUB HandleErr
PRINT Length&; "bytes long."


'---- Clip the file to be 2 bytes shorter.
NewLength& = LofFile&(Handle) - 2
CALL ClipFile(Handle, NewLength&)
PRINT "The file has been clipped successfully.  ";


'---- Prove that the clipping worked successfully.
Length& = LofFile&(Handle)
GOSUB HandleErr
PRINT "It is now"; Length&; "bytes long."


'---- Close the file.
CALL CloseFile(Handle)
GOSUB HandleErr
PRINT "The file was successfully closed."


'---- Open the file again, this time for shared access.
OpenMethod = 66                 'full sharing, read/write
CALL OpenFile(FileName$, OpenMethod, Handle)
GOSUB HandleErr
PRINT FileName$; " successfully opened in shared mode";
PRINT ", handle:"; Handle


'---- Lock bytes 50 through 59.
Start& = 50
Length& = 10
Action = 0                      'specify locking
CALL LockFile(Handle, Start&, Length&, Action)
GOSUB HandleErr
PRINT "File bytes 50 through 59 are successfully locked."


'---- Prove that it is locked by asking DOS to copy it.
PRINT "DOS (another process) fails to access the file:"
SHELL "COPY " + FileName$ + " NUL"


'---- Unlock the same range of bytes (mandatory).
Start& = 50
Length& = 10
Action = 1                      'specify unlocking
CALL LockFile(Handle, Start&, Length&, Action)
GOSUB HandleErr
PRINT "File bytes 50 through 59 successfully unlocked."


'---- Prove the unlocking worked by having DOS copy it.
PRINT "Once unlocked DOS can access the file:";
SHELL "COPY " + FileName$ + " NUL"


CloseIt:
'---- Close the file
CALL CloseFile(Handle)
GOSUB HandleErr
PRINT "The file was successfully closed, ";


'---- Kill the file to be polite
CALL KillFile(FileName$)
GOSUB HandleErr
PRINT "and then successfully deleted."

END

'=======================================
'  Error handler
'=======================================
HandleErr:

TempErr = DOSError%             'call DOSError% just once
IF TempErr = 0 THEN RETURN      'return if no errors
PRINT ErrMessage$(TempErr)      'else print the message
IF TempErr = 1 THEN             'we failed trying to lock
  COLOR 7 + 16
  PRINT "SHARE must be installed to continue."
  COLOR 7
  RETURN CloseIt
ELSE                            'otherwise end
  END
END IF


SUB ClipFile (Handle, Length&) STATIC
  '-- Use SeekFile to seek there, and then call WriteFile
  '   specifying zero bytes to truncate it at that point.
  '   Length& + 1 is needed because we need to seek just
  '   PAST the point where the file is to be truncated.
  CALL SeekFile(Handle, Length& + 1, Zero)
  IF ErrCode THEN EXIT SUB    'exit if an error occurred
  CALL WriteFile(Handle, Dummy, Dummy, Zero)
END SUB


SUB CloseFile (Handle) STATIC
  ErrCode = 0                   'assume no errors
  Registers.AX = &h4E00         'close file service
  Registers.BX = Handle         'using this handle
  CALL DOSInt(Registers)
  IF Registers.Flags AND 1 THEN ErrCode = Registers.AX
END SUB


FUNCTION DOSError%
  DOSError% = ErrCode           'simply return the error
END FUNCTION


FUNCTION ErrMessage$ (ErrNumber) STATIC
  SELECT CASE ErrNumber
    CASE 2
      ErrMessage$ = "File not found"
    CASE 3
      ErrMessage$ = "Path not found"
    CASE 4
      ErrMessage$ = "Too many files"
    CASE 5
      ErrMessage$ = "Access denied"
    CASE 6
      ErrMessage$ = "Invalid handle"
    CASE 61
      ErrMessage$ = "Disk full"
    CASE ELSE
      ErrMessage$ = "Undefined error: " + STR$(ErrNumber)
  END SELECT
END FUNCTION


SUB FlushFile (Handle) STATIC
  ErrCode = 0                   'assume no errors
  Registers.AX = &H4500         'create duplicate handle
  Registers.BX = Handle         'based on this handle

  CALL DOSInt(Registers)
  IF Registers.Flags AND 1 THEN 'an error, assign it
    ErrCode = Registers.AX
  ELSE                          'no error, so closing the
    TempHandle = Registers.AX   'dupe flushes the data
    CALL CloseFile(TempHandle)
  END IF
END SUB


SUB KillFile (FileName$) STATIC
  ErrCode = 0                      'assume no errors
  LocalName$ = FileName$ + CHR$(0) 'make an ASCIIZ string

  Registers.AX = &H4100            'delete file service
  Registers.DX = SADD(LocalName$)  'using this handle
  Registers.DS = SSEG(LocalName$)  'use this with PDS
 'Registers.DS = -1                'use this with QB

  CALL DOSInt(Registers)
  IF Registers.Flags AND 1 THEN ErrCode = Registers.AX
END SUB


FUNCTION LocFile& (Handle) STATIC
  ErrCode = 0               'assume no errors

  Registers.AX = &H4201     'seek to where we are now
  Registers.BX = Handle     'using this handle
  Registers.CX = 0          'move zero bytes from here
  Registers.DX = 0

  CALL DOSInt(Registers)
  IF Registers.Flags AND 1 THEN    'an error occurred
    ErrCode = Registers.AX
  ELSE                             'adjust to one-based
    LocFile& = (Registers.AX + (65536 * Registers.DX)) + 1
  END IF
END FUNCTION


SUB LockFile (Handle, Location&, NumBytes&, Action) STATIC
  ErrCode = 0                     'assume no errors
  LocalLoc& = Location& - 1       'adjust to zero-based

  Registers.AX = Action + (256 * &H5C)  'lock/unlock
  Registers.BX = Handle
  Registers.CX = PeekWord%(VARSEG(LocalLoc&), VARPTR(LocalLoc&) + 2)
  Registers.DX = PeekWord%(VARSEG(LocalLoc&), VARPTR(LocalLoc&))
  Registers.SI = PeekWord%(VARSEG(NumBytes&), VARPTR(NumBytes&) + 2)
  Registers.DI = PeekWord%(VARSEG(NumBytes&), VARPTR(NumBytes&))

  CALL DOSInt(Registers)
  IF Registers.Flags AND 1 THEN ErrCode = Registers.AX
END SUB


FUNCTION LofFile& (Handle)
  '---- first get and save the current file location
  CurLoc& = LocFile&(Handle) 'LocFile also clears ErrCode
  IF ErrCode THEN EXIT FUNCTION

  Registers.AX = &H4202      'seek to the end of the file
  Registers.BX = Handle      'using this handle
  Registers.CX = 0           'move zero bytes from there
  Registers.DX = 0

  CALL DOSInt(Registers)
  IF Registers.Flags AND 1 THEN  'an error occurred
    ErrCode = Registers.AX
    EXIT FUNCTION
  ELSE                           'assign where we are
    LofFile& = Registers.AX + (65536 * Registers.DX)
  END IF

  Registers.AX = &H4200     'seek to where we were before
  Registers.BX = Handle     'using this handle
  Registers.CX = PeekWord%(VARSEG(CurLoc&), VARPTR(CurLoc&) + 2)
  Registers.DX = PeekWord%(VARSEG(CurLoc&), VARPTR(CurLoc&))

  CALL DOSInt(Registers)
  IF Registers.Flags AND 1 THEN ErrCode = Registers.AX
END FUNCTION


SUB OpenFile (FileName$, Method, Handle) STATIC
  ErrCode = 0                          'assume no errors
  Registers.AX = Method + (256 * &h4D) 'open file service
  LocalName$ = FileName$ + CHR$(0) 'make an ASCIIZ string

  DO
    Registers.DX = SADD(LocalName$) 'point to the name
    Registers.DS = SSEG(LocalName$) 'use this with PDS
   'Registers.DS = -1               'use this w/QuickBASIC

    CALL DOSInt(Registers)              'call DOS
    IF (Registers.Flags AND 1) = 0 THEN 'no errors
      Handle = Registers.AX         'assign the handle
      EXIT SUB                      'and we're all done
    END IF

    IF Registers.AX = 2 THEN        'File not found error
      Registers.AX = &h4C00         'so create it!
    ELSE
      ErrCode = Registers.AX        'read the code from AX
      EXIT SUB
    END IF
  LOOP
END SUB


SUB ReadFile (Handle, Segment, Address, NumBytes) STATIC
  ErrCode = 0                   'assume no errors

  Registers.AX = &h4F00         'read from file service
  Registers.BX = Handle         'using this handle
  Registers.CX = NumBytes       'and this many bytes
  Registers.DX = Address        'read to this address
  Registers.DS = Segment        'and this segment

  CALL DOSInt(Registers)
  IF Registers.Flags AND 1 THEN ErrCode = Registers.AX
END SUB


SUB SeekFile (Handle, Location&, Method) STATIC
  ErrCode = 0                      'assume no errors
  LocalLoc& = Location& - 1        'adjust to zero-based

  Registers.AX = Method + (256 * &H42)
  Registers.BX = Handle
  Registers.CX = PeekWord%(VARSEG(LocalLoc&), VARPTR(LocalLoc&) + 2)
  Registers.DX = PeekWord%(VARSEG(LocalLoc&), VARPTR(LocalLoc&))

  CALL DOSInt(Registers)
  IF Registers.Flags AND 1 THEN ErrCode = Registers.AX
END SUB


SUB WriteFile (Handle, Segment, Address, NumBytes) STATIC
  ErrCode = 0                      'assume no errors

  Registers.AX = &H4000
  Registers.BX = Handle
  Registers.CX = NumBytes
  Registers.DX = Address
  Registers.DS = Segment

  CALL DOSInt(Registers)
  IF Registers.Flags AND 1 THEN
    ErrCode = Registers.AX
  ELSEIF Registers.AX <> Registers.CX THEN
    ErrCode = 61
  END IF
END SUB

This program begins by dimensioning two variables as SHARED throughout the entire module. By establishing the Registers TYPE variable as SHARED, all of the routines can use the same portion of DGROUP memory. If a separate DIM statement were used within each procedure, that many copies of this 20- byte variable would reside in memory at once. The CRLF$ variable does not need to be shared, because it is used only by the demonstration portion of the program.

Before I describe each of these routines and how they are used, it is important to explain how DOS uses file handles. BASIC is unique among languages in that it allows you to make up an arbitrary file number that is used to access the files. With most languages and operating systems--and DOS is no exception--it is the operating system that assigns a number which your program must remember. Therefore, when you call the OpenFile routine to open a file, the Handle parameter is returned to you and you will use that number for subsequent file operations.

Another important point is how errors are handled by these routines. Since you do not use ON ERROR to trap those situations another method is needed. Each routine clears or sets a global SHARED variable named ErrCode, which indicates its success or failure. After each call to one of these routines you will then check this variable, to see if it was successful. For the most efficiency, this program invokes a central error checking GOSUB routine that performs the actual testing. If an error occurs this routine prints an appropriate message using the ErrMessage$ function, and then ends. The DOSError function is provided to allow access to ErrCode from other modules.

In practice, it is not strictly necessary to add an explicit test after each subroutine call. For example, if you know the file has been opened successfully and you are sure the disk drive has sufficient space, then it is probably safe to assume that subsequent file writes will be okay. However, if you do call a routine that causes an error and don't check for that error, the next successful call to another routine will clear ErrCode and you will have no way to know about the earlier error.

Opening a File

The demonstration begins by first assigning a file name and open method, and then calling OpenFile to open the file. The open method lets you indicate the file access mode (reading, writing, or both), and also if the file will be accessed on a network. This parameter is bit-coded, and each bit has a parallel equivalent in BASIC's ACCESS READ, WRITE, SHARED, LOCK READ, and LOCK WRITE options. Figure 11-4 shows how these bits are organized.


 7   6   5   4   3   2   1   0  < Bits
n/a  64  32  16 n/a  4   2   1  < Numeric Values
       
                      
                 Access Mode
              Reserved
     Sharing Mode
  Inheritance
Figure 11-4: The organization of the bits that establish how a file is to be opened.

As with the file attribute bits shown earlier in Figure 11-3, you also need to set bits individually here to fully control the various file permission privileges. The access mode bits are valid with DOS versions 2.0 or later, and are equivalent to BASIC's ACCESS arguments. The sharing mode bits require DOS 3.0 or later, and also require SHARE.EXE to be installed. Note that some network software does not explicitly require SHARE, and provides the same functionality as part of its normal operation.

The three lower bits control the file access, using the following binary code: 000 establishes read-only access, 001 allows writing only, and 010 allows both reading and writing. The term access as used here means what actions *your* program can perform, and has nothing to do with network or file sharing privileges.

File sharing privileges are controlled by the three bits in the upper nybble (half-byte), and these determine what actions may be performed by other programs while your file is open. Regardless of what sharing (or locking) options you choose, your program always has full permission to access the file. The share bits are organized as follows: 000 means sharing is disabled, and this is what you must specify if you are not running on a network or when DOS 2.x is installed. A code of 001 denies other programs access to either read from or write to the file, 010 allows other programs to read but not write, and 011 allows writing but not reading. A code of 100 indicates full sharing, which lets other programs read and write, as long as that part of the file is not locked explicitly.

Again, these codes are presented as binary values, and it is up to you to determine the correct value based on the settings of the individual bits. This is not as hard as it may sound at first, because you simply add up the bit values shown in the table. For example, to open a file for non- network read/write access under any version of DOS you use 000 + 010 = 2, which is the value used in the first OPEN example. To open a file for reading and writing and also allow other applications to access it fully you instead use 100 + 010 = 64 + 2 = 66. This is shown in the second OPEN statement. Figure 11-5 lists a few of the possible bit combinations, with the equivalent BASIC OPEN options.


      BASIC OPEN Statement             Bits    Value
=================================    ========  =====
OPEN FOR BINARY                      00000010     2
OPEN FOR BINARY ACCESS READ          00000000     0
OPEN FOR BINARY ACCESS WRITE         00000001     1
OPEN FOR BINARY ACCESS READ WRITE    00000010     2
OPEN FOR BINARY ACCESS READ SHARED   01000000    64
OPEN FOR BINARY LOCK READ            00110010    50
OPEN FOR BINARY LOCK WRITE           00100010    34
Figure 11-5: Bit equivalents for some of BASIC's OPEN options.

Reading and Writing

Once the file has been opened successfully, the next step is to show how to write a string variable in the same way BASIC does when you use PRINT #. The WriteFile and ReadFile routines each expect four arguments: the DOS file handle, the segment and address to save from or read into, and the number of bytes. These are the same parameters that DOS expects, and you can see by examining the subprograms that they merely pass this information on to DOS.

Just before the first call to WriteFile, Msg$ is assigned a short test string, and a carriage return and line feed are appended to it manually. Remember, when you use BASIC's PRINT # command it is BASIC that adds these bytes for you. When dealing with DOS directly it is up to you to append these characters. Of course, you would omit these to mimic appending a semicolon at the end of a BASIC print line:

     PRINT #1, Msg$;

SSEG then determines where the string data segment is, and SADD reports its address within that segment. The QuickBASIC version is shown as a comment, and it uses VARSEG instead. The number of bytes is obtained using LEN, and DOS accepts any value up to 65535. It is imperative that you never pass a value of zero for the number of bytes, or DOS will truncate the file at the current seek location. I will discuss this in more detail later on, in the section entitled *Beyond BASIC's File Handling*.

The next example that writes an integer variable to the file is similar, except it uses a fixed length of 2. BASIC will not let you pass different types of data to one subprogram or function, which is why these read and write routines are designed to accept a segment and address.

ReadFile is not called until later in the demonstration; however, it is nearly identical to WriteFile. Because you must tell ReadFile how many bytes are to be read, you should establish some type of system. One good one is the method used by Lotus and described in Chapter 6. For programs that do not need such a heavy-handed approach or that write only strings, you could use a simpler technique. For example, each string could be preceded by an integer length word, and that word would be read prior to reading each string. The short code fragment that follows shows how this might work.

     Segment = VARSEG(Length)      'Length is what gets read first
     Address = VARPTR(Length)
     CALL ReadFile(Handle, Segment, Address, 2)

     Work$ = SPACE$(Length)        'make a string that long
     Segment = SSEG(Work$)         'then read Length bytes into the string
     Address = SADD(Work$)
     CALL ReadFile(Handle, Segment, Address, Length)

Setting and Reading the DOS Seek Location

The LocFile and LofFile functions are similar to their BASIC LOC and LOF counterparts, except that LocFile is really equivalent to the SEEK function. Chapter 6 described the difference between the LOC and SEEK functions, and came to the inescapable conclusion that LOC is not nearly as useful as SEEK in most situations.

The SeekFile subprogram, on the other hand, is equivalent to the statement form of BASIC's SEEK, and offers an interesting twist as an enhancement. Where BASIC's SEEK statement expects an offset from the beginning of the file, DOS provides additional seek methods. One lets you seek relative to where you are now in the file, and the other is relative to the end of the file. Therefore, I have included a SeekMethod parameter with my version of SeekFile, letting you enjoy the same flexibility.

If SeekMethod is set to zero, DOS behaves the same as BASIC does and bases the new seek location from the beginning of the file. If SeekMethod is instead assigned to 1, the new offset into the file will be based on the current location. Note that you may use both positive *and* negative seek values, to move forward and backwards respectively. Finally, using a SeekMethod value of 2 tells DOS to consider the new location as being relative to the end of the file.

For this method you may also use either a positive or negative value, to go beyond the end of the file or some offset before the end. While there is nothing inherently wrong with seeking past the end of a file, if any data is written at that point DOS will make that the new file length. And as explained in Chapter 6, the portion of the file that lies between the previous end of the file and the current end will hold whatever junk happened to be in the sectors that were just assigned to extend the length.

One slight complication arises if you are dealing with fixed-length record data: you must calculate the appropriate file offset manually. The short one-line DEF FN function below shows how to do this.

  DEF FNSeekLoc&(RecNumber, RecLen) = ((RecNumber - 1) * CLNG(RecLen)) + 1

Locking a File

The LockFile subprogram serves the same purpose as BASIC's LOCK and UNLOCK statements. Because the code to lock and unlock a file are identical except for a single instruction, it seemed reasonable to combine the two services into one routine. LockFile expects four arguments: a handle, a starting offset, the number of bytes, and an action code. The starting offset and number of bytes use long integer values, to accommodate large files.

Because DOS's Lock and Unlock services require you to specify the range of bytes to be locked, additional effort may be needed on your part. For example, if you are manipulating fixed-length records it is up to you to translate record numbers and record ranges to an equivalent binary offset and number of bytes. Fortunately, these values are very easy to determine using the following formulas:

     Location& = (RecNumber - 1) * CLNG(RecLength)
     NumBytes& = RecLength * CLNG(NumRecords)

Note how CLNG is necessary to prevent BASIC from creating an overflow error if the result of the multiplications exceeds 32767.

LockFile can also be used with normal BASIC file handling statements, if you merely want to avoid an error from attempting to lock a file that is already locked by another process. This requires you to use BASIC's FILEATTR function to obtain the equivalent DOS handle, thus:

     Handle = FILEATTR(FileNumber, 2)

Here, FileNumber is the BASIC file number that was specified when the file was first opened. For example, if you used this:

     OPEN FileName$ FOR RANDOM SHARED AS #4 LEN = RecLength

then the correct value for FileNumber will be 4.

Beyond BASIC's File Handling

Aside from SeekFile's ability to use the end of a file or the current seek location as a base point, the routines presented so far merely mimic the same capabilities BASIC already provides. Two notable exceptions, however, are ClipFile and FlushFile.

The ClipFile subprogram lets you set a new length for a file, and that length may be either longer or shorter than the current length. ClipFile takes advantage of a little-known DOS feature that sets a new length for a file when you tell it to write zero bytes. This technique was used in the DBPACK.BAS program from Chapter 7, and it let that program remove deleted records from the end of a dBASE file.

ClipFile begins by calling SeekFile to move the DOS file pointer just past the new length specified. If no error occurred it then calls WriteFile to write zero bytes at that point, thus establishing the new length. Notice the way the undefined variable Zero is used rather than a literal constant 0. As you already learned in Chapter 2, when a constant is passed to a subprogram or function, BASIC creates code to store a copy of the constant in DGROUP, and then passes the address of that copy. Although the variable Zero also requires two bytes of DGROUP memory for storage, the code to explicitly place the value there is avoided. Since an unassigned variable is always zero this method can be used with confidence.

FlushFile also provides an important service that BASIC does not. When data is written to disk using either BASIC or DOS via direct interrupt calls, the last portion that was written is not necessarily on the physical disk. DOS buffers all file writes to minimize the number of disk accesses needed, thereby improving the speed of those writes. BASIC performs additional buffering as well, which further improves your program's performance. However, this creates a potential problem because a power outage or other disaster will cause any data in the file buffer to be lost.

FlushFile calls upon another little-known DOS service called Duplicate Handle. When this service is called with the handle of a file that is already open, DOS creates a duplicate handle for the same file. This service is not that useful in and of itself, except for one important exception: When the duplicate handle is subsequently closed, DOS also writes the original file's contents to disk and updates the directory entry to reflect the current length. This is exactly what FlushFile does to flush the file buffer to disk.

Error Messages

The ErrMessage$ function is designed to display an appropriate message if an error occurs while using these routines. DOS has fewer error codes than BASIC, and it also uses a completely different numbering system. The ErrMessage$ function returns an error message that is equivalent to BASIC's where possible, but based on the DOS error return codes.

Potential Problems

Although this collection of file handling routines offers many improvements over using equivalent BASIC statements, there is one important issue I have not addressed here: handling critical errors. A critical error is caused by attempting to access a floppy disk drive with the drive door open, or no disk in place. At the DOS command line critical errors result in the infamous "Abort, Retry, Fail" message.

Handling critical errors requires pure assembly language, and is a fairly complex undertaking. Therefore, I have purposely omitted that functionality from these routines. However, add-on library products such as QuickPak Professional and P.D.Q. from Crescent Software are written in assembly language, and include critical error handling.

There is another potential problem you must be aware of when using these routines. When you open a file using BASIC's OPEN statement, and then restart the program before the file has been closed, BASIC closes the file before running your program again. This is done automatically and without your knowing about it.

If you call OpenFile to open a file and then restart the program, the original file remains open. This causes no harm by itself--your program will simply receive the next available handle when it calls OpenFile. But at some point you will surely exhaust the available handles. The problem is that you will not be able to save your program, because the BASIC editor needs a handle when writing your source code to disk.

The solution is to press F6 to go to the Immediate window, and then type the following line:

     FOR X% = 5 TO 20: CALL CloseFile(X%): NEXT

This closes all of the files your program opened, thus freeing them for use by the BASIC editor. It is essential that you never close DOS handles zero through four, because they are in use by the PC. Since DOS uses these handles itself to print to the screen and read keyboard input, closing those handles will effectively lock up your PC. [Also, it is okay to close handles 5 through 20, even if your program hasn't opened that many. That is, asking DOS to close a file handle that was never opened does no harm.]

Accessing the Mouse

All of the DOS and BIOS system services we have looked at so far rely on either the Interrupt routine that comes with BASIC, or the simplified DOSInt replacement. In a similar fashion, accessing the mouse driver also requires you to call interrupts. All of the mouse services are invoked using Interrupt &h43, and like DOS and the BIOS they require you to load the processor's registers to pass information, and then read them again afterward to obtain the results.

In this section I will present several useful subroutines that show how to access the mouse interrupt. The first portion discusses the various utility routines, and shows how they are used. Following that, I will explain how the routines actually work and interface with the mouse driver.

Mouse Services

The important mouse services provided here are those that turn the mouse cursor on and off, position it on the screen and control its color, and let you determine which buttons are being pressed and where the cursor is presently located. Other routines show how to restrict the range of the mouse cursor's travel, and show how to define new, custom cursor shapes.

To reduce the size of your programs I have written a short assembly language subroutine called MouseInt. This is similar to the DOSInt routine introduced in Chapter 6, except it is intended for use with the mouse interrupt &h43.

;MOUSEINT.ASM

.Model Medium, Basic

MouseRegs Struc
  RegAX  DW ?
  RegBX  DW ?
  RegCX  DW ?
  RegDX  DW ?
  Segmnt DW ?
MouseRegs Ends

.Code

MouseInt Proc Uses SI DS ES, MRegs:Word
  Mov  SI,MRegs          ;get the address of MouseRegs
  Mov  AX,[SI+RegAX]     ;load each register in turn
  Mov  BX,[SI+RegBX]
  Mov  CX,[SI+RegCX]
  Mov  DX,[SI+RegDX]

  Mov  SI,[SI+Segmnt]    ;see what the segment is
  Or   SI,SI             ;is it zero?
  Jz   @F                ;yes, skip ahead and use default

  Cmp  SI,-1             ;is it -1?
  Je   @F                ;yes, skip ahead
  Mov  DS,SI             ;no, use the segment specified

@@:
  Push DS                ;either way, assign ES=DS
  Pop  ES
  Int  33h               ;call the mouse driver

  Push SS                ;regain access to MouseRegs
  Pop  DS

  Mov  SI,MRegs          ;access MouseRegs again
  Mov  [SI+RegAX],AX     ;save each register in turn
  Mov  [SI+RegBX],BX
  Mov  [SI+RegCX],CX
  Mov  [SI+RegDX],DX

  Ret                    ;return to BASIC
MouseInt Endp
End

Like DOSInt, this routine also uses a TYPE variable to define the various CPU registers that are needed by the mouse driver. However, fewer registers are needed simplifying the TYPE structure. You should define this TYPE variable as follows:

     TYPE MouseType
       AX      AS INTEGER
       BX      AS INTEGER
       CX      AS INTEGER
       DX      AS INTEGER
       Segment AS INTEGER
     END TYPE
     DIM MouseRegs AS MouseTYPE

Since the mouse driver uses only these few registers, you can save a few bytes of DGROUP memory by using this subset TYPE instead of the full Registers TYPE that DOSInt requires. Notice the last component called Segment. Unlike the Mouse routine that Microsoft sells as an add-on library, MouseInt lets you specify a segment for passing far data to the mouse interrupt handler. For most mouse services you can leave the segment set to zero or -1. Either value tells MouseInt to use BASIC's default data segment. But some services that accept the address of incoming data also need to know the data's segment.

In the Microsoft version you have no choice but to use static data and near memory arrays. Obviously, this precludes being able to use BASIC PDS far strings with that interface routine. You would instead have to create a single fixed-length string or TYPE variable, just to force the data to reside in near memory. When calling MouseInt with a value other than zero or -1 for the segment, MouseInt loads both DS and ES with that value.

As with the collection of DOS file access routines, the following subprograms and functions can be added as a module to your program. Again, you should first make a copy of the source file that is included on the accompanying floppy disk, and then delete the demonstration portion of the program. This way, you can also run the original demonstration, and trace through it to test each of the mouse services. Of course, be sure to leave the commands that dimension the MouseRegs and MousePresent variables as being shared, and also the relevant DECLARE and DEFINT statements.

'MOUSE.BAS, demonstrates the various mouse services

DEFINT A-Z

'---- assembly language functions and subroutines
DECLARE FUNCTION PeekWord% (BYVAL Segment, BYVAL Address)
DECLARE SUB MouseInt (MouseRegs AS ANY)


'---- BASIC functions and subprograms
DECLARE FUNCTION Bin2Hex% (Binary$)
DECLARE FUNCTION MouseThere% ()
DECLARE FUNCTION WaitButton% ()
DECLARE SUB CursorShape (HotX, HotY, Shape())
DECLARE SUB HideCursor ()
DECLARE SUB MouseTrap (ULRow, ULCol, LRRow, LRCol)
DECLARE SUB MoveCursor (X, Y)
DECLARE SUB ReadCursor (X, Y, Buttons)
DECLARE SUB ShowCursor ()
DECLARE SUB TextCursor (FG, BG)

DECLARE SUB Prompt (Message$)   'used for this demo only


TYPE MouseType                  'similar to DOS RegType
  AX      AS INTEGER
  BX      AS INTEGER
  CX      AS INTEGER
  DX      AS INTEGER
  Segment AS INTEGER
END TYPE

DIM SHARED MouseRegs AS MouseType
DIM SHARED MousePresent
REDIM Cursor(1 TO 32)

IF NOT MouseThere% THEN         'ensure a mouse is present
  PRINT "No mouse is installed" '  and initialize it if so
  END
END IF
CLS


DEF SEG = 0                     'see what type of monitor
IF PEEK(&H463) <> &HB4 THEN     'if it's color
  ColorMon = -1                 'remember that for later
  SCREEN 12                     'this requires a VGA
  LINE (0, 0)-(639, 460), 1, BF 'paint a blue background
END IF


DIM Choice$(1 TO 5)             'display some choices
LOCATE 1, 1                     'for something to point at
FOR X = 1 TO 5
  READ Choice$(X)
  PRINT Choice$(X);
  LOCATE , X * 12
NEXT
DATA "Choice 1", "Choice 2", "Choice 3"
DATA "Choice 4", "Choice 5"


IF NOT ColorMon THEN            'if it's not color
  CALL TextCursor(-2, -2)       'select a text cursor
END IF


CALL ShowCursor
CALL Prompt("Point the cursor at a choice, and press _
  a button.")


DO                              'wait for a button press
  CALL ReadCursor(X, Y, Button)
LOOP UNTIL Button
IF Button AND 4 THEN Button = 3 'for three-button mice

CALL Prompt("You pressed button" + STR$(Button) + _
  " and the cursor was at location" + STR$(X) + "," + _
  STR$(Y) + " - press a button.")

IF ColorMon THEN                'if it is a color monitor
  RESTORE Arrow                 '  load a custom arrow
  GOSUB DefineCursor
END IF
Dummy = WaitButton%


IF ColorMon THEN                'the hardware can do it
  RESTORE CrossHairs            'set a cross-hairs cursor
  GOSUB DefineCursor
  CALL Prompt("Now the cursor is a cross-hairs, press _
    a button.")
  Dummy% = WaitButton%
END IF


IF ColorMon THEN                'now set an hour glass
  RESTORE HourGlass
  GOSUB DefineCursor
END IF


CALL Prompt("Now notice how the cursor range is _
  restricted.  Press a button to end.")
CALL MouseTrap(50, 50, 100, 100)
Dummy = WaitButton%

IF ColorMon THEN                'restore to 640 x 350
  CALL MouseTrap(0, 0, 349, 639)
ELSE                            'use CGA bounds for mono!
  CALL MouseTrap(0, 0, 199, 639)
END IF


Dummy = MouseThere%             'reset the mouse driver
CALL HideCursor                 'and turn off the cursor
SCREEN 0                        'revert to text mode
END


DefineCursor:

FOR X = 1 TO 32                 'read 32 words of data
  READ Dat$                     'read the data
  Cursor(X) = Bin2Hex%(Dat$)    'convert to integer
NEXT
CALL CursorShape(Zero, Zero, Cursor())
RETURN


Arrow:

NOTES:
'The first group of binary data is the screen mask.
'The second group of binary data is the cursor mask.
'The cursor color is black where both masks are 0.
'The cursor color is XORed where both masks are 1.
'The color is clear where the screen mask is 1 and the
'  cursor mask is 0.
'The color is white where the screen mask is 0 and the
'  cursor mask is 1.
'
'Mouse cursor designs by Phil Cramer.

'--- this is the screen mask
DATA "1110011111111111"
DATA "1110001111111111"
DATA "1110000111111111"
DATA "1110000011111111"
DATA "1110000001111111"
DATA "1110000000111111"
DATA "1110000000011111"
DATA "1110000000001111"
DATA "1110000000000111"
DATA "1110000000000011"
DATA "1110000000000001"
DATA "1110000000011111"
DATA "1110001000011111"
DATA "1111111100001111"
DATA "1111111100001111"
DATA "1111111110001111"

'---- this is the cursor mask
DATA "0001100000000000"
DATA "0001010000000000"
DATA "0001001000000000"
DATA "0001000100000000"
DATA "0001000010000000"
DATA "0001000001000000"
DATA "0001000000100000"
DATA "0001000000010000"
DATA "0001000000001000"
DATA "0001000000000100"
DATA "0001000000111110"
DATA "0001001100100000"
DATA "0001110100100000"
DATA "0000000010010000"
DATA "0000000010010000"
DATA "0000000001110000"


CrossHairs:

DATA "1111111101111111"
DATA "1111111101111111"
DATA "1111111101111111"
DATA "1111000000000111"
DATA "1111011101110111"
DATA "1111011101110111"
DATA "1111011111110111"
DATA "1000000111000000"
DATA "1111011111110111"
DATA "1111011101110111"
DATA "1111011101110111"
DATA "1111000000000111"
DATA "1111111101111111"
DATA "1111111101111111"
DATA "1111111101111111"
DATA "1111111111111111"

DATA "0000000010000000"
DATA "0000000010000000"
DATA "0000000010000000"
DATA "0000111111111000"
DATA "0000100010001000"
DATA "0000100010001000"
DATA "0000100000001000"
DATA "0111111000111111"
DATA "0000100000001000"
DATA "0000100010001000"
DATA "0000100010001000"
DATA "0000111111111000"
DATA "0000000010000000"
DATA "0000000010000000"
DATA "0000000010000000"
DATA "0000000000000000"


HourGlass:

DATA "1100000000000111"
DATA "1100000000000111"
DATA "1100000000000111"
DATA "1110000000001111"
DATA "1110000000001111"
DATA "1111000000011111"
DATA "1111100000111111"
DATA "1111110001111111"
DATA "1111110001111111"
DATA "1111100000111111"
DATA "1111000000011111"
DATA "1110000000001111"
DATA "1110000000001111"
DATA "1100000000000111"
DATA "1100000000000111"
DATA "1100000000000111"

DATA "0000000000000000"
DATA "0001111111110000"
DATA "0000000000000000"
DATA "0000111111100000"
DATA "0000100110100000"
DATA "0000010001000000"
DATA "0000001010000000"
DATA "0000000100000000"
DATA "0000000100000000"
DATA "0000001010000000"
DATA "0000011111000000"
DATA "0000110001100000"
DATA "0000100000100000"
DATA "0000000000000000"
DATA "0001111111110000"
DATA "0000000000000000"


FUNCTION Bin2Hex% (Binary$) STATIC  'binary to integer
  Temp& = 0
  Count = 0

  FOR X = LEN(Binary$) TO 1 STEP -1
    IF MID$(Binary$, X, 1) = "1" THEN
      Temp& = Temp& + 2 ^ Count
    END IF
    Count = Count + 1
  NEXT

  IF Temp& > 32767 THEN Temp& = Temp& - 65536
  Bin2Hex% = Temp&
END FUNCTION


SUB CursorShape (HotX, HotY, Shape()) STATIC
  IF NOT MousePresent THEN EXIT SUB

  MouseRegs.AX = 9
  MouseRegs.BX = HotX
  MouseRegs.CX = HotY
  MouseRegs.DX = VARPTR(Shape(1))
  MouseRegs.Segment = VARSEG(Shape(1))

  CALL MouseInt(MouseRegs)
END SUB


SUB HideCursor STATIC       'turns off the mouse cursor
  IF NOT MousePresent THEN EXIT SUB

  MouseRegs.AX = 2
  CALL MouseInt(MouseRegs)
END SUB


FUNCTION MouseThere% STATIC 'reports if a mouse is present
  MouseThere% = 0           'assume there is no mouse
  IF PeekWord%(Zero, (4 * &h43) + 2) = 0 THEN 'segment = 0
    EXIT FUNCTION           '  means there's no mouse
  END IF

  MouseRegs.AX = 0
  CALL MouseInt(MouseRegs)
  MouseThere% = MouseRegs.AX
  IF MouseRegs.AX THEN MousePresent = -1
END FUNCTION


SUB MouseTrap (ULRow, ULColumn, LRRow, LRColumn) STATIC
  IF NOT MousePresent THEN EXIT SUB

  MouseRegs.AX = 7           'restrict horizontal movement
  MouseRegs.CX = ULColumn
  MouseRegs.DX = LRColumn
  CALL MouseInt(MouseRegs)

  MouseRegs.AX = 8           'restrict vertical movement
  MouseRegs.CX = ULRow
  MouseRegs.DX = LRRow
  CALL MouseInt(MouseRegs)
END SUB


SUB MoveCursor (X, Y) STATIC 'positions the mouse cursor
  IF NOT MousePresent THEN EXIT SUB

  MouseRegs.AX = 4
  MouseRegs.CX = X
  MouseRegs.DX = Y
  CALL MouseInt(MouseRegs)
END SUB


SUB Prompt (Message$) STATIC 'prints prompt message
    V = CSRLIN               'save current cursor position
    H = POS(0)
    LOCATE 30, 1             'use 25 for EGA SCREEN 9
    CALL HideCursor          'this is very important!
    PRINT LEFT$(Message$, 79); TAB(80);
    CALL ShowCursor          'and so is this
    LOCATE V, H              'restore the cursor
END SUB


SUB ReadCursor (X, Y, Buttons)  'returns cursor and button
                                '  information
  IF NOT MousePresent THEN EXIT SUB

  MouseRegs.AX = 3
  CALL MouseInt(MouseRegs)

  Buttons = MouseRegs.BX AND 7
  X = MouseRegs.CX
  Y = MouseRegs.DX
END SUB


SUB ShowCursor STATIC        'turns on the mouse cursor
  IF NOT MousePresent THEN EXIT SUB

  MouseRegs.AX = 1
  CALL MouseInt(MouseRegs)
END SUB


SUB TextCursor (FG, BG) STATIC
  IF NOT MousePresent THEN EXIT SUB

  MouseRegs.AX = 10
  MouseRegs.BX = 0
  MouseRegs.CX = &HFF
  MouseRegs.DX = 0

  IF FG = -1 THEN        'maintain FG as the cursor moves?
    MouseRegs.CX = MouseRegs.CX OR &HF00
  ELSEIF FG = -2 THEN    'invert FG as the cursor moves?
    MouseRegs.CX = MouseRegs.CX OR &H700
    MouseRegs.DX = &H700
  ELSE                   'use the specified color
    MouseRegs.DX = 256 * (FG AND &HFF)
  END IF

  IF BG = -1 THEN        'maintain BG as the cursor moves?
    MouseRegs.CX = MouseRegs.CX OR &HF000
  ELSEIF BG = -2 THEN    'invert BG as the cursor moves?
    MouseRegs.CX = MouseRegs.CX OR &H7000
    MouseRegs.DX = MouseRegs.DX OR &H7000
  ELSE                   'use the specified color
    Temp = (BG AND 7) * 16 * 256
    MouseRegs.DX = MouseRegs.DX OR Temp
  END IF

  CALL MouseInt(MouseRegs)
END SUB


FUNCTION WaitButton% STATIC     'waits for a button press
  IF NOT MousePresent THEN EXIT FUNCTION

  X! = TIMER                    'pause to allow releasing
  WHILE X! + .2 > TIMER         '  the button
  WEND

  DO                            'wait for a button press
    CALL ReadCursor(X, Y, Button)
  LOOP UNTIL Button

  IF Button AND 4 THEN Button = 3 'for three-button mice
  WaitButton% = Button            'assign the function
END FUNCTION

This program begins by declaring all of the support functions, and then defines and dimensions the MouseRegs TYPE variable. The integer array is used to hold the custom graphics cursor shape information, which the CursorShape routine requires. The remainder of the program illustrates how to use the various mouse routines in your own programs.

(2) Determining if a Mouse is Present

The first function is MouseThere, which serves two important purposes: The first is to determine if a mouse is present. The second purpose of MouseThere is to initialize the mouse driver to its default parameters. This lets you be sure that the mouse color, shape, and other parameters are in a known state. Resetting the mouse is strongly recommended because some programs do not bother to reset the mouse when they are finished.

Although there is a mouse service to determine if the driver is installed, you must also perform an additional test to prevent problems with early computers running DOS version 2. The problem arises because these computers leave the mouse interrupt (&h43) undefined if no mouse is present, and calling this interrupt is likely to make the PC crash.<>/p

As you already know, the interrupt vector table in low memory holds the segment and address for every interrupt service routine that is present in the PC. But who puts those addresses into the interrupt vector table? All of the BIOS interrupt addresses are assigned by the BIOS as part of the power-up code in your PC's ROM. Likewise, DOS installs the addresses it needs while it is being loaded from disk.

The BIOS in modern computers assigns every interrupt vector to a valid address, even those that it (the BIOS) does not use. The code pointed to by the unused interrupts is an assembly language Iret (Interrupt Return) instruction. So if no other routine is servicing that interrupt, calling it merely returns with no change to the register contents. But early computers and early versions of DOS ignored Interrupt &h43, and left the values in that vector address set to zero. [Calling the "code" at address zero is guaranteed to fail, since address zero holds other addresses and not executable code.] Therefore, to safely detect the presence of a mouse requires first looking in low memory, to ensure that the interrupt address there is valid.

It is important to understand that you *must* use MouseThere once at the start of your program, before any of the other mouse routines will work. All of the mouse routines check the global variable MousePresent before calling MouseInt, and do nothing if it is zero. This safety mechanism lets you freely call the various mouse services without regard to whether or not a mouse is installed, to avoid the DOS 2 problem described earlier. Thus, the same program statements can accommodate a mouse if one is present or not, without requiring many separate IF tests.

For example, you will probably want to write programs that use a mouse if one is present, but don't require it. If you had to have a separate block of code for each case, your program would be much larger and slower than necessary. Therefore, you can simply call these mouse routines whether or not a mouse is present. The code fragment that follows shows a simple example of this in context.

     PRINT "Press a key or mouse button to continue: ";
     DO
       Temp$ = INKEY$
       CALL ReadCursor(X, Y, Buttons)
     LOOP UNTIL LEN(INKEY$) OR Buttons
     PRINT "Thank you."

If MouseThere determined that no mouse was present when it was called earlier, then ReadCursor will do nothing and return no values. Of course, you will have to check for mouse events and act on them, but these can be handled within the same blocks of code that also handle keyboard input.

Once the program knows that a mouse is in fact present, it checks to see if the display adapter is color or monochrome. A color monitor supports more mouse options such as changing the shape of the mouse cursor. In this case the program assumes that you have a VGA adapter. If you have only an EGA, simply change the SCREEN 12 statement to SCREEN 9. You will also have to change the LOCATE command in the Prompt subprogram to use line 25 instead of line 30. Although the cursor shape can be altered with CGA and Hercules adapters, those are not accommodate here.

Once the screen display mode is set, a filled box is drawn covering the entire screen, to create an attractive blue background. You should be aware that the drivers included come with many older, inexpensive clone mouse devices do not support the EGA and VGA display modes. This is not a limitation with the mouse hardware; rather, the problem lies in the driver software. Fortunately, the MOUSE.COM and MOUSE.SYS drivers that Microsoft includes with BASIC work with most brands of mouse. Furthermore, you are allowed to distribute those drivers with your own programs, as long as you include an appropriate copyright notice. See the license agreement that came with your version of BASIC for more information on displaying the Microsoft copyright.

Controlling the Text Cursor

After reading and displaying a list of sample choices that serve as a menu, the program again checks to see which type of adapter is present. If it is monochrome, then a custom text cursor is defined using the TextCursor routine. This routine is appropriate for both monochrome and color adapters, and offers several useful options that let you control fully how the foreground and background colors will appear. Also, an initial call to TextCursor is needed with some non-Microsoft mouse drivers to ensure that the cursor is displayed after calling ShowCursor.

TextCursor expects two parameters to control the cursor's foreground and background colors. If a positive value is given for either parameter, then that is the color the mouse cursor assumes as it travels around the screen. For example, if you use a color combination of 0, 4 the character under the mouse cursor will be shown in black on a red background. It is important to understand that the normal mouse cursor color is actually the character's background color. The foreground indicates what color the text is to become as the cursor passes over it.

Using a value of -1 for either parameter tells the mouse driver to leave that portion of the color alone when the cursor is positioned over a character. If you use a color combination of 7, -1 the text under the mouse cursor will be shown in white and the background will be unchanged. Of course, if both the foreground and background are set to -1, the cursor will never be visible.

A value of -2 causes that color portion to be inverted using an XOR process as the cursor moves around the screen. That is, white becomes black, green turns to magenta, and blue is translated to brown. Although a value of -2 for the background guarantees that the cursor is always visible, it can also be distracting to see the mouse cursor color change constantly when the screen itself uses many colors. If you want to experiment with the various TextColor options, add remarking apostrophes to deactivate the three statements after the line IF PEEK(&H463) <> &HB4 THEN near the beginning of the program.

The ShowCursor subprogram simply tells the mouse drive to make the mouse cursor visible, in much the same way LOCATE , , 1 option does with the normal screen cursor. The companion routine HideCursor turns the mouse cursor off again. These are very simple routines that do not require much explanation; however, please understand that until you turn the cursor on explicitly it remains hidden. As a rule, you also want to ensure that the cursor is turned off before you end your program and return to DOS.

There is one irritating quirk about how the mouse driver keeps track of whether the mouse cursor is currently visible or not. When you use the statement LOCATE , , 0 to turn off the regular text cursor, the BIOS remembers that it is off. And if you subsequently use the same statement again the request is ignored. The mouse driver, on the other hand, remembers how many times you called HideCursor and requires a corresponding number of calls to ShowCursor before it becomes visible. However, the reverse is not true. If you turn on the cursor, say, five times in a row, only one call to HideCursor is needed to turn it off.

Reading the Mouse Buttons and Cursor Position

The next mouse routine is called ReadCursor, and it calls the service that returns both the current mouse cursor position and also which buttons are currently pressed. Notice that the X and Y values returned assume graphics pixel coordinates even when the display screen is in text mode! Therefore, when a monochrome display adapter is being used, the values returned range from 0 to 639 horizontally (X), and 0 through 199 vertically (Y). These are the same values you would receive when in CGA black and white screen mode 2. When in graphics mode, the X and Y values are based on the current SCREEN setting. For example, in EGA screen mode 9, the returned value for X ranges from 0 through 639, and Y is between 0 and 349.

When your program is in text mode (SCREEN 0), the current X and Y cursor location is based on the upper-left corner of the mouse cursor box. Therefore, the actual horizontal range (X) is usually returned between 0 and 632 to account for a box width of 8 pixels. The vertical location (Y) ranges from 0 to 192 for the same reason: If the bottom of the cursor is at the bottom of the screen, then the top is eight pixels higher. In graphics mode you are allowed to establish any portion of the mouse cursor as being the *hot spot*, and this is discussed below in the section "Changing the Mouse Cursor Shape".

The buttons are returned bit coded--the lowest bit is set if button 1 is pressed, and the next bit is set when the second button is pressed. If a mouse has three buttons, the third bit may also be set to indicate that. Isolating which bit or combination of bits is set is done using the AND logic operator. If Button AND 1 is non-zero then the first button is pressed. Similarly, Button AND 2 means the second button is being pressed. However, testing for button 3 requires a value of 4, since that is the value of the third bit. The program fragment that follows shows this in context, and you can press one or more buttons at a time.

DO 
  PRINT "Press Ctrl-Break to end."
  CALL ReadCursor(X, Y, Button) 

  LOCATE 10, 1 
  IF Button AND 1 THEN 
    PRINT "BUTTON 1" 
  ELSE 
    PRINT "        " 
  END IF 

  LOCATE 10, 11 
  IF Button AND 2 THEN 
    PRINT "BUTTON 2" 
  ELSE 
    PRINT "        " 
  END IF 

  LOCATE 10, 21 
  IF Button AND 4 THEN 
    PRINT "BUTTON 3" 
  ELSE 
    PRINT "        " 
  END IF 
LOOP 

Besides the ReadCursor routine which returns the cursor position and button status, I have also included a related function called WaitButton. If your program will be waiting for a button and needs to know which button was pressed, WaitButton does this using fewer bytes of compiler-generated code. Since there are no passed parameters only five bytes are needed to call WaitButton, compared to 17 needed to call ReadCursor. WaitButton simply waits in an empty loop until a button is pressed, and then reports which button it was.

Changing the Mouse Cursor Shape

The CursorShape routine lets you change the size and shape of the mouse cursor when the display is in graphics mode. The mouse driver routine that is called requires the address of a block of memory 32 words long that holds the new shape and color information. The data in this memory block is organized into two sections. The first 16 words hold what is called the *screen mask*, and the second 16 words hold the *cursor mask*.

The bits in these masks interact to change the way the foreground and background colors on the screen change as the cursor passes over them. The method used by the mouse driver to control the cursor shape and colors is very complex, and the examples and discussions in Microsoft's documentation do little to assist the programmer. Therefore, I have provided a simple mechanism that lets you draw the cursor shape using a series of BASIC DATA statements.

Using this method it is easy to control each individual pixel in the mouse cursor, and determine if it is white, black, or transparent. When the bits in both the screen and cursor masks are both zero, the cursor will be black. And when the bits in both masks are set to 1, the color is XORed (reversed) at that pixel position. If a screen mask bit is 1 and its corresponding bit in the cursor mask is 0, the cursor is transparent. Reversing this to make the screen mask 0 and the cursor mask 1 makes the cursor white at that position. Thus, you can create nearly any shape for the mouse cursor, and a wide variety of interesting color effects.

If your needs are modest or to minimize the number of DATA statements, you can define only the cursor mask and use -1 for the first 16 elements in the array by changing that portion of the program like this:

DefineCursor:

FOR X = 1 TO 32                'read 32 words of data
  IF X < 17 THEN               'set first 16 elements = -1
    Cursor(X) = -1
  ELSE                         'and for the second 16
    READ Dat$                  '  read the data and then
    Cursor(X) = Bin2Hex%(Dat$) '  convert to an integer
  END IF
NEXT

DATA "1100000000000000"        'use only 16 DATA items
DATA "1110000000000000"        '  in this section
 .
 .

The other two parameters required by CursorShape are the X and Y cursor hot spots. When you call ReadCursor to return the current mouse cursor location and button information, the X and Y position returned identifies a single pixel on the screen. Which pixel within the mouse cursor that is reported is the cursor hot spot. When you use an arrow cursor shape, the hot spot is typically the tip of the arrow. This is located in the upper left corner of the cursor box and is identified as location 0, 0. However, you can also make any other portion of the cursor the hot spot. For simplicity, the GOSUB routine at the DefineCursor label always uses 0, 0. However, the cross hairs cursor really should use the values 8, 8 to set the hot spot at the center of the block.

Controlling the Mouse Cursor Position and Range

The MoveCursor routine lets you set a new position for the mouse cursor, and it too expects pixel values even when the screen is in text mode. Although MoveCursor is not demonstrated in this program, it is included in the interest of completeness.

The final mouse subprogram included lets you restrict the range of mouse cursor travel, and it is called--appropriately enough--MouseTrap. You pass the upper-left and lower-right boundaries to MouseTrap, and it in turns passes those values on to the mouse driver. Internally, the mouse driver lets you restrict the range for horizontal and vertical motion independently. But for simplicity this routines requires both sets of values at one time.

Like the services that ReadCursor and MoveCursor call, these services also expect the cursor bounds to be given as pixels even when in text mode. Also, notice that the mouse driver always forces the cursor into the restricted region for you. That is, if the cursor is in the upper-left corner and you call MouseTrap forcing it to stay inside the bottom half of the screen, it will be moved to the top of that region.

Be aware that MouseTrap is also required if you plan to use the 43- or 50-line EGA and VGA text modes. By default, the mouse driver assumes that a text screen has only 25 lines, and will not normally let the mouse cursor be placed below that line. If you have used WIDTH , 50 to put the screen into the 50-line mode, the mouse cursor will not be allowed below line 25. Therefore, you must use MouseTrap to increase the allowable cursor region beyond the default range. Also be aware that using values larger than the current screen dimensions let the mouse disappear off the bottom of the screen, or wrap around past the right edge and reappear on the left side.

Accessing the Mouse Driver

All of the mouse routines considered so far are comprised of a simplified interface to the mouse driver through the MouseInt routine. MouseInt lets you access any service supported by the mouse driver, including those that I have not described here. Similar to the various DOS and BIOS services, the mouse driver expects a service number in the AX register. The other registers contain the various expected parameters and returned information, and they vary from service to service.

There are no errors returned by the mouse driver, so no mechanism is needed to handle errors. For example, if you tell the mouse driver to position the cursor off the top edge of the screen, it simply ignores you.

Unfortunately, discussing every possible mouse service goes beyond what I could ever hope to include in a book about BASIC. If you want to learn more about the services that are available to you, I recommend purchasing a good technical reference such as the Microsoft Mouse Programmer's Reference. Other mouse manufacturers also publish their own technical manuals, and make them available to the public for a small charge. Thankfully, all of the mouse services are consistent across brands, although some brands include more features than defined by Microsoft. Unless you write programs only for your own use, you should avoid relying on services that are specific to a single manufacturer.

Accessing Expanded Memory

The last set of routines I will present show how you can use interrupts to access an expanded memory (EMS) driver. Expanded memory has been available for many years, and it provides a way to exceed the normal 640K RAM barrier imposed by the 8088 microprocessors. Newer computers that use an 80286 or later processors can use what is called Extended Memory (XMS), and this type of memory will eventually become the standard way for all computers in the future to access more than 1MB of memory. Unfortunately, accessing the extended memory beyond 1MB on an 80286-based PC is complicated by a design deficiency in that CPU chip. Many people are confused about the difference between Expanded and Extended memory, so perhaps a brief explanation is in order.

Extended memory is a single contiguous block that starts at address zero and extends through the highest address available, based on the amount of memory that is present in a PC. Expanded memory, on the other hand, is more complex, and uses a technique called *bank switching*. With bank switching, a large amount of memory (up to 16 megabytes) is made available to the CPU in 16K blocks. Each of these blocks is called a page, and only four of them can be accessed at one time. Thus, the term bank switching is appropriate because various banks of far memory are switched in and out of a near memory address space.

The EMS standard requires a 64K contiguous area of near memory within the 1MB addressable range to be reserved for use by the EMS driver as a *page frame*. On my own PC the 64k address range from &HE000:0000 through &HE000:FFFF is not used for any other purpose, and is therefore available for use by an EMS driver. At any given time, the four 16K blocks of memory within this segment can be connected to memory that lies outside of the 1MB normal address range.

Hardware plug-in EMS boards such as the Intel Above Board contain their expanded memory on the board itself. EMS emulator software instead converts the Extended memory on computers so equipped to be accessible through the 64K segment within the EMS page frame. This is achieved through hardware switches that allow any area of memory to be remapped to any other range of addresses. In either case, however, Expanded memory is made available to an application one page at a time as near memory.

Each of the four 16K near memory pages in the EMS page frame are called *physical pages*, because they reside in physical memory that can be accessed directly by the CPU. However, many pages of far EMS memory are available--up to four at a time--and these are called *logical pages*. This is shown graphically in Figure 11-6.


                                              
                                                           
                                              
                                                           
                                              
                                        Ĵ
                                      /          Page 73  
1MB boundary -->  Ŀ    /   Ĵ
                    ROM BIOS    /   /          Page 72  
               >͵/   /   Ĵ
                    Page 3     /   /                   
                 Ĵ/   /                     
      Physical      Page 2     /                       
      Pages      Ĵ
                    Page 1                    Page 45  
                 Ĵ
                    Page 0      \                      
               >Ĵ\    \Ĵ
                     DISPLAY    \              Page 38  
                     MEMORY       \Ĵ
640K boundary --> ͵                          
                                                        
                     Normal                      EMS    
                       DOS                     Logical
                     Memory                       Pages   
                                             
                                                          
                                             
                  
    Address 0 --> 
Figure 11-6: How EMS logical pages in far memory are mapped onto physical pages in conventional memory.

Here, physical page 0 is connected to logical page 38 in expanded memory, physical page 1 to logical page 45, and so forth. Whenever a program wants to access a particular logical page in expanded memory, it calls the EMS driver telling it to map that page to one of the four physical pages in the page frame segment. Then, the EMS logical page can be accessed at the near memory address within the page frame.

For simplicity, all of the routines provided here to handle Expanded memory use physical page 0 only. Since these routines merely copy array data back and forth between conventional and Expanded memory, the data can be copied in blocks of 16K and there is no need to have to map multiple pages simultaneously. Therefore, these routines always map physical page 0 to whichever logical page needs to be accessed, and then copy the data in that page only.

EMS Services

As with the DOS services accessed through Interrupt &H21, the EMS driver also uses handles to identify which data you are working with. When memory is allocated using EMS Interrupt &H67, you tell the driver how many 16K pages you are requesting, and if there is sufficient memory available it returns a handle. It should come as no surprise to learn that these parameters are passed using the CPU registers. Also like DOS and the BIOS, the EMS driver expects a service number in the AH Register. For example, the service that requests memory is specified with AH set to &H43.

To minimize the amount of code that is added to your programs, I have created a short assembly language subroutine called EMSInt that replaces the Interrupt routine included with BASIC. As with DOSInt and MouseInt, this routine lets you pass only the parameters that are actually needed, to reduce the amount of compiler-generated code. EMSInt needs access only to the AX, BX, CX, and DX registers, so these are the only components in the EMSType TYPE structure shown below.

     TYPE EMSType
       AX AS INTEGER
       BX AS INTEGER
       CX AS INTEGER
       DX AS INTEGER
     END TYPE

Unlike BASIC's Interrupt routine that has to deal with three parameters and code to generate any interrupt number, EMSInt itself is relatively simple:

;EMSINT.ASM

.Model Medium, Basic

EMSRegs Struc
  RegAX DW ?
  RegBX DW ?
  RegCX DW ?
  RegDX DW ?
EMSRegs Ends

.Code

EMSInt Proc Uses SI, ERegs:Word
  Mov  SI,ERegs          ;get the address of EMSRegs
  Mov  AX,[SI+RegAX]     ;load each register in turn
  Mov  BX,[SI+RegBX]
  Mov  CX,[SI+RegCX]
  Mov  DX,[SI+RegDX]

  Int  67h               ;call the EMS driver

  Mov  SI,ERegs          ;access EMSRegs again
  Mov  [SI+RegAX],AX     ;save each register in turn
  Mov  [SI+RegBX],BX
  Mov  [SI+RegCX],CX
  Mov  [SI+RegDX],DX

  Ret                    ;return to BASIC
EMSInt Endp
End

If you plan to use the mouse and EMS routines in the same program, you could use the MouseRegs variable for both and ignore the Segment portion when call EMSInt.

The program that follows combines a demonstration portion and a collection of subprograms and functions. Notice that like the various mouse services, you *must* query EMSThere to ensure that an EMS driver is loaded before any of the other routines can be used.

'EMS.BAS, demonstrates the EMS memory services

DEFINT A-Z

DECLARE FUNCTION Compare% (BYVAL Seg1, BYVAL Adr1, BYVAL Seg2, _
  BYVAL Adr2, NumBytes)
DECLARE FUNCTION EMSErrMessage$ (ErrNumber)
DECLARE FUNCTION EMSError% ()
DECLARE FUNCTION EMSFree& ()
DECLARE FUNCTION EMSThere% ()
DECLARE FUNCTION PeekWord% (BYVAL Segment, BYVAL Address)

DECLARE SUB EMSInt (EMSRegs AS ANY)
DECLARE SUB EMSStore (Segment, Address, ElSize, NumEls, Handle)
DECLARE SUB EMSRetrieve (Segment, Address, ElSize, NumEls, Handle)
DECLARE SUB MemCopy (BYVAL FromSeg, BYVAL FromAdr, BYVAL ToSeg, _
  BYVAL ToAdr, NumBytes)

TYPE EMSType                    'similar to DOS Registers
  AX    AS INTEGER
  BX    AS INTEGER
  CX    AS INTEGER
  DX    AS INTEGER
END TYPE

DIM SHARED EMSRegs AS EMSType
DIM SHARED ErrCode
DIM SHARED PageFrame


CLS
IF NOT EMSThere% THEN           'ensure EMS is present
  PRINT "No EMS is installed"
  END
END IF

PRINT "This computer has"; EMSFree&;
PRINT "kilobytes of EMS available"

REDIM Array#(1 TO 20000)
FOR X = 1 TO 20000
  Array#(X) = X
NEXT

CALL EMSStore(VARSEG(Array#(1)), VARPTR(Array#(1)), 8, 20000, Handle)
IF EMSError% THEN
  PRINT EMSErrMessage$(EMSError%)
  END
END IF

REDIM Array#(1 TO 20000)
CALL EMSRetrieve(VARSEG(Array#(1)), VARPTR(Array#(1)), 8, 20000, Handle)
IF EMSError% THEN
  PRINT EMSErrMessage$(EMSError%)
  END
END IF

FOR X = 1 TO 20000              'prove it worked
  IF Array#(X) <> X THEN PRINT ".";
NEXT
END


FUNCTION EMSErrMessage$ (ErrNumber) STATIC
  SELECT CASE ErrNumber
    CASE 128
      EMSErrMessage$ = "Internal error"
    CASE 129
      EMSErrMessage$ = "Hardware malfunction"
    CASE 131
      EMSErrMessage$ = "Invalid handle"
    CASE 133
      EMSErrMessage$ = "No handles available"
    CASE 135, 136
      EMSErrMessage$ = "No pages available"
    CASE ELSE
      IF PageFrame THEN
        EMSErrMessage$ = "Undefined error: " + STR$(ErrNumber)
      ELSE
        EMSErrMessage$ = "EMS not loaded"
      END IF
  END SELECT
END FUNCTION


FUNCTION EMSError% STATIC
  Temp& = ErrCode
  IF Temp& < 0 THEN Temp& = Temp& + 65536
  EMSError% = Temp& \ 256
END FUNCTION


FUNCTION EMSFree& STATIC
  EMSFree& = 0              'assume failure
  IF PageFrame = 0 THEN EXIT FUNCTION

  EMSRegs.AX = &H4200
  CALL EMSInt(EMSRegs)
  ErrCode = EMSRegs.AX      'save possible error from AH

  IF ErrCode = 0 THEN EMSFree& = EMSRegs.BX * 16
END FUNCTION


SUB EMSRetrieve (Segment, Address, ElSize, NumEls, Handle) STATIC
  IF PageFrame = 0 THEN EXIT SUB

  LocalSeg& = Segment           'use copies we can change
  LocalAdr& = Address

  BytesNeeded& = NumEls * CLNG(ElSize)
  PagesNeeded = BytesNeeded& \ 16384
  Remainder = BytesNeeded& MOD 16384
  IF Remainder THEN PagesNeeded = PagesNeeded + 1

  NumBytes = 16384              'assume we're copying a 
                                '  complete page
  ThisPage = 0                  'start copying to page 0

  FOR X = 1 TO PagesNeeded      'copy the data
    IF X = PagesNeeded THEN     'watch out for last page
      IF Remainder THEN NumBytes = Remainder
    END IF

    IF LocalAdr& > 32767 THEN   'handle segment boundaries
      LocalAdr& = LocalAdr& - &H8000&
      LocalSeg& = LocalSeg& + &H800
      IF LocalSeg& > 32767 THEN 
        LocalSeg& = LocalSeg& - 65536
      END IF
    END IF

    EMSRegs.AX = &H4400       'map physical page 0 to the
    EMSRegs.BX = ThisPage     '  current logical page
    EMSRegs.DX = Handle       '  for the given handle
    CALL EMSInt(EMSRegs)      'then copy the data there
    ErrCode = EMSRegs.AX      'save possible error from AH
    IF ErrCode THEN EXIT SUB
    CALL MemCopy(PageFrame, Zero, CINT(LocalSeg&), CINT(LocalAdr&), _
      NumBytes)

    ThisPage = ThisPage + 1
    LocalAdr& = LocalAdr& + NumBytes
  NEXT

  EMSRegs.AX = &H4500           'release memory service
  EMSRegs.DX = Handle
  CALL EMSInt(EMSRegs)
  ErrCode = EMSRegs.AX          'save possible error
END SUB


SUB EMSStore (Segment, Address, ElSize, NumEls, Handle) STATIC

  IF PageFrame = 0 THEN EXIT SUB

  LocalSeg& = Segment           'use copies we can change
  LocalAdr& = Address

  BytesNeeded& = NumEls * CLNG(ElSize)
  PagesNeeded = BytesNeeded& \ 16384
  Remainder = BytesNeeded& MOD 16384
  IF Remainder THEN PagesNeeded = PagesNeeded + 1

  EMSRegs.AX = &H4300       'allocate memory service
  EMSRegs.BX = PagesNeeded
  CALL EMSInt(EMSRegs)

  ErrCode = EMSRegs.AX      'save possible error from AH
  IF ErrCode THEN EXIT SUB
  Handle = EMSRegs.DX       'save the handle returned

  NumBytes = 16384          'assume we're copying a 
                            '  complete page
  ThisPage = 0              'start copying to page 0

  FOR X = 1 TO PagesNeeded      'copy the data
    IF X = PagesNeeded THEN     'watch out for last page
      IF Remainder THEN NumBytes = Remainder
    END IF

    IF LocalAdr& > 32767 THEN   'handle segment boundaries
      LocalAdr& = LocalAdr& - &H8000&
      LocalSeg& = LocalSeg& + &H800
      IF LocalSeg& > 32767 THEN 
        LocalSeg& = LocalSeg& - 65536
      END IF
    END IF

    EMSRegs.AX = &H4400       'map physical page 0 to the
    EMSRegs.BX = ThisPage     '  current logical page
    EMSRegs.DX = Handle       '  for the given handle
    CALL EMSInt(EMSRegs)      'then copy the data there
    ErrCode = EMSRegs.AX      'save possible error from AH
    IF ErrCode THEN EXIT SUB
    CALL MemCopy(CINT(LocalSeg&), CINT(LocalAdr&), PageFrame, Zero, _
      NumBytes)

    ThisPage = ThisPage + 1
    LocalAdr& = LocalAdr& + NumBytes
  NEXT
END SUB


FUNCTION EMSThere% STATIC
  EMSThere% = 0                 'assume the worst
  DIM DevName AS STRING * 8
  DevName = "EMMXXXX0"          'search for this below

  '---- Try to find the string "EMMXXXX0" at offset 10 in the EMS handler.
  '     If it's not there then EMS cannot possibly be installed.
  Int67Seg = PeekWord%(0, (&H67 * 4) + 2)
  IF NOT Compare%(Int67Seg, 10, VARSEG(DevName$), VARPTR(DevName$), 8) THEN
    EXIT FUNCTION
  END IF

  EMSRegs.AX = &H4100     'get Page Frame Segment service
  CALL EMSInt(EMSRegs)
  ErrCode = EMSRegs.AX    'save possible error from AH

  IF ErrCode = 0 THEN
    EMSThere% = -1
    PageFrame = EMSRegs.BX
  END IF
END FUNCTION

EMS.BAS begins by declaring all of the subprograms and functions that it uses, as well as the EMSType structure. The three shared variables are used by the various procedures, and should not be removed when you delete the demo portion to create a reusable module.

Determining if EMS is Present

The first function used is EMSThere, which reports if an EMS driver is loaded and operative. EMSThere begins by assuming that an EMS driver is not loaded, and assigns a function output value of 0. Then it attempts to find the device name "EMMXXXX0" in the header portion of the EMS device driver. Like the MouseThere function that checked the interrupt vector table for a non-zero segment value, this preliminary check is also needed to prevent a system lockup on older computers running DOS version 2.

To search for this string EMSThere uses PeekWord to retrieve the segment for Interrupt &H67, and then looks at the eight bytes at offset 10 within that segment. If the Compare function finds the unique identifying string, it knows that the driver is loaded and it is safe to invoke Interrupt &H67. Service &H41 returns either -1 in AX if the driver is active, or 0 if it is not. This service also returns the page frame segment the driver is using in near memory, and EMSThere saves this value in the shared variable PageFrame for access by the other routines.

Determining Available EMS Memory

The second function, EMSFree, returns the number of 16K EMS pages that are available to your program. The remainder of the demonstration simply dimensions a 20,000 element double precision array, and then saves it to expanded memory. Because this array exceeds 64K, you must start BASIC with the /ah command line switch. Otherwise you will receive a "Subscript out of range" error message.

EMSFree uses function &H42 to ask the EMS driver for the number of free pages, and the driver returns the page count in BX. Although it is not shown here, service &H42 also returns the total number of pages in the DX register. Therefore, you could easily create a TotalPages function from a copy of EMSFree by changing the line that assigns the function output to instead be IF ErrCode = 0 THEN TotalPages& = EMSRegs.DX * 16.

Storing and Retrieving Data

The actual storing and retrieving of data to and from Expanded memory is fairly complicated, because of the need to map different logical pages to physical page zero. Although Figure 11-6 shows a single group of logical pages, the EMS driver really maintains a separate series of logical pages for each active handle.

EMSStore and EMSRetrieve store and retrieve data in Expanded memory respectively, and both of these subprograms are designed to accommodate huge arrays larger than 64k. Therefore, additional work is needed to calculate new segment values as each 16K portion has been processed.

As with all of the EMS procedures shown here, EMSStore begins by verifying that EMSThere has already been invoked, and that a valid page frame segment has been obtained. The next step is to make long integer copies of the incoming segment and address parameters. Because of the segment arithmetic that is performed later in the routine, long integers are needed to allow values greater than 32,767 to be compared. Equally important, a routine should never alter incoming parameters unless they also return information or such changes are expected.

Next, EMSStore determines the total number of bytes of EMS storage that are needed, and from that calculates the total number of 16K pages. Because the EMS driver allocates entire pages only, an odd number of bytes requires an entire additional page. BASIC's MOD function is used for this, and if the result is non-zero, the TotalPages variable is incremented.

Once the number of pages is known, service &H43 is called to allocate the Expanded memory. The remainder of the procedure walks through the array data in 16K increments, mapping physical page zero to the next logical page in sequence. Note the code that tests the current address to see if it is within 32K of spanning a segment boundary. In that case, the address is dropped by 32K, and the segment is increased by an equivalent amount. Because each new segment starts 16 bytes higher than the previous one, 32K \ 16 is added to LocalSeg& rather than a full 32K.

After the array is stored in EMS, it is redimensioned in the demonstration and then retrieved using the EMSRetrieve subprogram. EMSRetrieve is nearly identical to EMSStore, except it copies from EMS to the array, and releases memory when it is finished rather than claim it at the beginning. The final step in the demonstration is to examine the value in each element, to prove that the array was restored correctly.

Detecting EMS Errors

The EMSError function retrieves the current value of ErrCode, and manipulates it into a form useable by your programs. EMS errors are returned in the AH register, which requires dividing by 256 to derive a single byte value. But since EMS error numbers start at 128, the value returned in AX appears negative to BASIC programs which treat all integers as being signed. This is why a long integer is used initially and then converted to a positive value, before dividing to produce the final result.

The EMSErrMessage function can be used to display an appropriate message if an error is detected. The incoming error code is filtered through a series of CASE statements, based on the error values defined by the EMS specification.

Suggested Enhancements

The routines presented herein provide a limited set of services for accessing Expanded memory. However, there are several improvements you can make, and a few other uses that I have not shown. If you are using BASIC PDS [or VB/DOS], one useful enhancement you can add is to change the subprograms and functions to receive their parameters by value using the BYVAL option. In fact, this can also be done with the DOS and mouse routines, to minimize the amount of code the BASIC compiler adds to your final executable program.

Although this demonstration shows storing array data only, you can also use these routines to store and retrieve text and graphics screens. This is much quicker than saving them to disk, as was shown in Chapter 6. For example, to save a 25 line by 80 column color text screen in Expanded memory you would use the appropriate segment and address like this:

     CALL EMSStore(&HB800, 0, 1, 4000, Handle)
     CALL EMSRetrieve(&HB800, 0, 1, 4000, Handle)

Just as you can cause problems by failing to close DOS handles during the development of a program, the same problem can happen with an EMS driver. Unfortunately, it is not as easy to know which handle numbers are still open if you have not kept track of them yourself manually. DOS issues its handles using a sensible series of sequential numbers. This is not necessarily the case with EMS handles. The EMM386.EXE driver provided by Microsoft does issue sequential handles, starting with handle 1. But many drivers use other starting values, some work from high numbers backwards, and yet others use a handle number sequence that is not in order.

Finally, to learn about all of the possible EMS services you need a good reference. Although the primary services are shown here, there are several others you may find useful. For example, service &H46 lets you retrieve the EMS version number, and service &H4C lets you see how many pages are currently allocated for a given handle. The EMS driver version can be valuable, because newer drivers offer more features which you may want to take advantage of. Ray Duncan's book "Advanced MS-DOS" mentioned earlier is one good source, and it lists each EMS service and the possible errors that can be returned.

Summary

In this chapter you learned how BASIC--and indeed, all languages--use interrupts to communicate with the operating system. You learned what interrupts are and how to access them, and how the CPU registers are used to communicate information between your program and the interrupt handler being invoked. You also learned how some of the two-byte registers can be treated as two one-byte registers, which requires multiplying and dividing to access those portions individually.

A number of complete programs were presented showing how to access the BIOS, DOS, the mouse driver, and Expanded memory. In the section on BIOS interrupts, examples were given that showed how to simulate pressing the PrtSc key, and also how to call the video service that clears or scrolls only a portion of the display screen.

The DOS examples included a complete set of subroutines to replace BASIC's file handling statements. One advantage gained by bypassing BASIC is to read and write large amounts of data at one time. Another is to avoid the need for ON ERROR in certain programming situations. Although calling the DOS services directly can be beneficial in many cases, it also requires more work on your part. However, some services cannot be accessed using BASIC alone, such as reading file and directory names, or determining a file's attribute. Where BASIC employs string descriptors to know how long a string is, DOS instead uses a CHR$(0) zero byte to mark the end.

The mouse and Expanded memory discussions described how those interrupt services are accessed, and provided practical advice and warnings where appropriate. Although a large number of interrupt routines were described, there is a practical limit to how much information can be provided here. In particular, you will need a separate reference manual that describes the details of each interrupt service routine in depth.

In the next and final chapter you will learn how to program in assembly language, and how to add assembly language routines to programs you write using BASIC. Assembly language is unlike any high-level language, and it provides the ultimate means to exploit fully all of the resources in a PC.