# Aros/Developer/Docs/Devices1.3

* Navbar for the Aros wikibook
* Aros User
* Aros User Docs
* Aros User FAQs
* Aros User Applications
* Aros User DOS Shell
* Aros/User/AmigaLegacy
* Aros Dev Docs
* Aros Developer Docs
* Porting Software from AmigaOS/SDL
* For Zune Beginners
* Zune .MUI Classes
* For SDL Beginners
* Aros Developer BuildSystem
* Specific platforms
* Aros x86 Complete System HCL
* Aros x86 Audio/Video Support
* Aros x86 Network Support
* Aros Intel AMD x86 Installing
* Aros Storage Support IDE SATA etc
* Aros Poseidon USB Support
* x86-64 Support
* Motorola 68k Amiga Support
* Linux and FreeBSD Support
* Windows Mingw and MacOSX Support
* Android Support
* Arm Raspberry Pi Support
* PPC Power Architecture
* misc
* Aros Public License
AMIGA DEVICE DRIVER GUIDE
Copyright (c) 1990 Markus Wandel
Version 0.12, May 21, 1990.
Distribution: Free in unmodified form.
Disclaimer: I don't know what I'm talking about; no guarantee is made of the correctness of anything in this document. Not all of this will conform to Commodore sanctioned programming and documentation practices. Version 1.4/2.0 of the Amiga OS may render part of this obsolete, particularly where I mention the internals of exec functions.
Corrections: If you find something wrong and would like it corrected, get in touch with me so I can have an up-to-date master copy. Currently I can be reached at (613) 591-7698. Should this number become invalid, call my parents at (705) 785-3383 or (705) 736-2285 (summer) and get the current one.
TABLE OF CONTENTS
0. INTRODUCTION 1. DEVICE STRUCTURE
2. DEVICE I/O PROTOCOL
3. GENERIC COMMAND AND ERROR NUMBERS
4. DISK DEVICE DRIVERS
5. REFERENCES 6. REVISION HISTORY
0. INTRODUCTION
A number of people have asked me to explain Amiga device drivers to them. There is a lot to explain, and so I decided to write down all I know about the subject. Here is the result.
This is not a standalone document. It assumes that you are familiar with Amiga programming at the multiple task, message passing level. It assumes that you have the autodocs, include files, and the example device driver from Commodore. A lot of information from these documents is duplicated, but not all.
I wish that I knew more about this subject than I do and that I was a better writer. Alas, I don't and I'm not. This document contains all you need to write a disk resident, autoloading and expungeable device driver, but you may have to read it more than once. To write a good, removeable-media disk driver, you will have to do some additional research.
1. DEVICE STRUCTURE
This section describes the data structure associated with a loaded device, and how to get a device into the system. As far as discussed in this section, libraries and devices are identical.
1.1. DEVICE NODES
A device is known to the system by its device node. The device node consists of three parts:
The "device address" is the base address of the library node; thus the jump table is at negative offsets from the address, and everything else is at positive offsets.
Each entry in the jump table is a "jmp" to a 32-bit address. Thus the first jump is at offset -6 from the device address, the second at offset -12, and so forth. The following four are standard for all devices and libraries:
These are the entry points for opening, closing, and expunging (unloading) the device, respectively. The last one appears to be for future expansion.
Device drivers have two more standard functions:
These are the entry points for submitting an I/O request and cancelling a pending one, respectively.
The library structure is shown below in "unwound" form.
The device is chained on the system device list by the node structure at the top. The device list can be found at ExecBase->DeviceList. Of interest is the "ln_Type" field, which must be NT_DEVICE, and the "ln_Name" field, which must be the device name in standard form, such as "serial.device".
The "lib_PosSize" and "lib_NegSize" fields indicate the number of bytes used above and below the device base address. Thus "lib_NegSize" is the size of the jump vector, and "lib_PosSize" is the size of the library structure plus the user's private data, if any.
"lib_Version", "lib_Revision", and "lib_IdString" store more information about the device. For "serial.device", version 34, revision 12, the string would look like this:
This appears to be the accepted standard format.
The "lib_Sum" field, along with the flag bits "LIBF_SUMMING", "LIBF_CHANGED", and "LIBF_SUMUSED" are for the jump vector checksum mechanism, which is discussed farther on.
The "lib_OpenCnt" field counts the number of times that the device is currently open. If this is not zero and an expunge is requested, the device can use the "LIBF_DELEXP" flag to remember that it should disappear at the earliest opportunity.
1.2. CONSTRUCTING A DEVICE NODE
In theory, you could just allocate all the memory needed for the device node, manually initialize the jump vector and the required fields in the library node, get ExecBase->DeviceList, do a Forbid, and add the node to the list using Enqueue, AddHead, or AddTail. But there is an easier way. It is the following function:
This takes an initialized device node, does the Forbid/Permit, and adds the node to ExecBase->DeviceList. It also calls Sumkick (discussed later). The node itself can be constructed with this function:
The first parameter points to the table of function addresses for the jump vector. It can have one of the following formats:
The "dataSize" parameter determines "positive size" of the device node. It must be at least the size of a library structure. The "negative size" is implicit from the number of jump vector entries supplied.
The "MakeLibrary" function will allocate the appropriate amount of memory, fill in the jump vector, then use InitStruct to clear and initialize the positive offset area. The "structure" parameter points to a data table for the InitStruct function. This function is a complex thing, best used with the macros supplied. Below is the table in Commodore's example device driver:
It means this:
If you want to know more about the Initstruct function (which is very flexible and can do more than suggested above), you should read the autodoc for it and possibly the disassembly as well.
Finally, we have the "init" and "segList" parameters. "init" is the address of the device's own initialization code. This is called after construction of the node is complete. "segList" describes where the device's code is loaded. The initialization code is called with the device node address in D0 and the SegList parameter in A0. The value it returns is the value returned by MakeLibrary. Thus the initialization code will normally return the device node address as it received it.
The only remaining thing to note is that the "structure" and "init" fields can be set to zero to not initialize the positive offset area and not call any initialization code, respectively.
So to summarize what we have so far, the first-principles way to get a device into the system is the following:
1.3. STANDARD DEVICE FORMAT
Real devices are in a standard format, which allows them to be loaded and initialized without any knowledge of their internals. The standard format is based on a "resident structure", or "RomTag". Such structures tag the various libraries and devices in the system ROM and in the LIBS: and DEVS: directories on disk. The structure is the following:
The first two fields are special, allowing the structure to be found during a ROM scan. They are the official 68000 "illegal instruction", opcode 0x4AFC, followed by a pointer to the instruction. The next field speeds up the ROM scan by pointing to the address at which the scan should continue, usually at the end of the area tagged by this RomTag.
The "rt_Version" field has the familiar version number, and the "rt_Type" field is NT_DEVICE for our application. The "rt_Pri" field, together with the "rt_Version" field, determines which of multiple modules with the same name will be accepted, should such a situation occur. The "rt_Name" and "rt_IdString" fields identify the module; they should be set to the device name and IdString as discussed earlier for a device node.
Things tagged by RomTags are referred to as "resident modules", and are initialized like this:
Here, "resident" points to the RomTag, and "segList" identifies the code associated with it (this is optional).
The InitResident function first checks if the RTF_AUTOINIT flag is set in the RomTag. If it is not, it simply calls the routine pointed to by "rt_Init" with A0 containing the value passed as "segList", and returns.
If the RTF_AUTOINIT flag is set, then the function does all the device initialization work for you. In this case, "rt_Init" points to a data table of four longwords, containing these parameters for the "MakeLibrary" function:
InitResident calls the MakeLibrary function with these values, plus the obligatory "segList" parameter. Unless MakeLibrary returns null, the new library node is now added to the appropriate system list, based on the "ln_Type" field in the node. If it is NT_DEVICE, the node is added to the device list, which is what we want.
You should now have enough information to create a standard device header and initialization function, and to understand the one in Commodore's example device driver.
1.4. DEVICES IN ROM
Devices in ROM are found during a scan of the entire ROM for RomTag structures. This occurs at boot time. The RomTags which are found are added to the resident module list in ExecBase. All those whose RTF_COLDSTART flag is set are automatically started up with InitResident.
Modules can be "ramkicked" using a mechanism which I will not describe here. This causes them to survive a reset, be found in RAM, and be initialized along with the ROM modules at cold start time. This is how the RAD: bootable RAM disk works.
1.5. DEVICES ON DISK
Devices on disk reside in the DEVS: directory. They are standard object modules, and loaded with LoadSeg. After they have been loaded, the first hunk of the seglist is scanned for a RomTag, and the device is initialized with an InitResident. When building a disk resident device, be careful that the linker does not add a dummy hunk at the front of your code (old versions of BLINK do this), as this would cause your RomTag not to be found.
1.6. JUMP VECTOR CHECKSUMS
The "lib_Sum" field in the library structure can be used to hold a checksum of the jump vector, to guard against accidental or unauthorized modification. The "LIBF_SUMUSED" flag bit indicates that this should be done. The "LIBF_CHANGED" flag bit indicates that the jump vector has been modified and the checksum is invalid. The following function implements the mechanism:
This does the following:
Thus when creating a new device node, it is best to set the LIBF_SUMUSED and LIBF_CHANGED bits, so that the first call to SumLibrary updates the checksum and future calls detect modifications to the jump vector.
The system sets the LIBF_CHANGED bit and calls SumLibrary as part of the SetFunction call to keep the checksum valid.
2. DEVICE I/O PROTOCOL
This section describes the interface to a device once it is loaded and initialized.
2.1. THE I/O REQUEST STRUCTURE
The system communicates with a device driver by use of an "I/O Request" structure. The most common version of this is shown below, but all fields after the "io_Error" field are device driver specific and may be omitted or redefined.
The first field in the I/O request is a standard message structure, allowing it to be enqueued on message ports and replied to. The "io_Device" field points to the device structure, and the "io_Unit" field points to a "unit" structure maintained by the device driver for each functional unit (e.g. disk drive). Thus these two fields identify the target of the I/O request.
2.2. OPENING AND CLOSING A DEVICE
To communicate with a device, one needs at least one I/O request, and a message port to which the device can return I/O requests which it has finished processing. These are easily created using the following two C library functions:
The first function creates a message port and associated signal bit, and returns the address of the port. The second function creates an IOStdReq structure, fills in the address of the reply port, and returns the address of the structure. Then the device driver can be opened using this function:
The parameters are the name of the device (e.g. "trackdisk.device", the unit number (e.g. 0 for the internal drive), the address of the finished I/O request structure, and a "flags" word containing special information for the device driver. The following C code will open the trackdisk driver for the internal drive, and close it again:
OpenDevice and CloseDevice are exec functions, but are intercepted by something called "ramlib.library", which handles the loading and unloading of disk resident devices and libraries. Thus the OpenDevice call may cause the device to be loaded and initialized, and the CloseDevice may cause it to be unloaded.
The OpenDevice eventually results in the initialized device driver being called through its Open function (at offset -6 in the jump vector). This occurs in the context (task) of the calling program. The device driver is passed the following information:
The exec will have already cleared the "io_Error" field in the I/O request, and stored the device node pointer in the "io_Device" field.
If the Open function succeeds, it must initialize the "io_Unit" field for later uses of the I/O request. If it fails, it must set the "io_Error" field to the appropriate number. The value of the "io_Error" field on exit is returned by the OpenDevice function.
The device driver should keep track of the number of outstanding opens using the "lib_OpenCnt" field in its device node. Some device drivers can support an arbitrary number of concurrent opens (e.g. disk drivers), while others can be opened in "exclusive access" modes (e.g. serial ports).
The unit number and flags are two 32-bit words whose format is up to the device driver writer. For an example unit numbering scheme, see the include file "devices/scsidisk.h", and for some example uses of the flag bits, see "devices/serial.h".
The CloseDevice call eventually results in the device driver being called through its Close function, at offset -12 in the jump vector. The I/O request pointer is passed in A1, and the device node pointer in A6. The return value determines what happens with the closed device. If it is zero, the device is kept around. If it is non-zero, it means that the device has performed a delayed expunge and wishes to be unloaded. In this case, the return value is the "segList" parameter which was passed to the device at initialization time. More of this in the next section.
2.3. EXPUNGING A DEVICE
A device can be deleted from the system using this function:
The system may issue this function itself, if it runs out of memory and tries to reclaim space. Either way, the device is called through its Expunge entry point, at offset -18 in the jump vector. Register A6 is set up to point to the device node.
The device should now shut down its activity, i.e. remove interrupt servers, deallocate buffers, and so on. Then it should unlink its device node from the device list, and deallocate the node, thus restoring things to the state they were in just before it was started up with InitResident. Finally, it should return the "segList" parameter which was passed to it at initialization time. If the device came from disk, the system will use this to unload its code.
If the device is not idle when the Expunge call arrives, it can defer the operation. To do this, it sets the LIBF_DELEXP flag in the library structure, and returns zero. This indicates that it will delete itself at the earliest opportunity. When the last Close call arrives, it will shut itself down just as described above, and return the segList value to indicate that it has done so and should be unloaded.
2.4. UNIT STRUCTURES
Many device drivers manage more than one functional unit. For example, the trackdisk driver can handle up to four floppy drives. The preferred approach is to use a separate "Unit" structure for each functional unit (e.g. drive). Normally, a unit structure consists of at least the following:
When the device driver is opened, it uses the unit number to select the appropriate unit structure, and stores the pointer to this structure in the I/O request. Later, it can queue up pending I/O requests on the message port for processing by the unit task.
2.5. THE BEGINIO FUNCTION
All I/O requests enter the device driver through the BeginIO function in its jump vector. The device driver is entered in the context of the requesting task, with A6 pointing to the device node and A1 pointing to the I/O request structure.
Normally, the device driver will now use PutMsg to enqueue the I/O request on a message port (in a Unit structure) for processing by an internal task. Then it can return from the BeginIO function. When the exec checks to see if the I/O request is completed yet, it checks its type field, and if it is NT_MESSAGE (as results from the PutMsg call) it knows that it is still in progress. Eventually, the internal task receives the I/O request, operates on it, and does a ReplyMsg. This returns the I/O request to the caller's reply port, and also sets its type to NT_REPLYMSG, signaling that it is finished.
It is clear that the device driver does not have to follow this procedure exactly. Short commands (such as checking if a disk is ready) can just be done immediately, in the caller's context. The I/O request must simply be returned with ReplyMsg at the end, and its type field must be something other than NT_REPLYMSG if the BeginIO function returns with the I/O request not completed yet.
A special case of I/O processing is signaled by the IOF_QUICK flag. When it is set, it means that the requester has used the DoIO function, and thus will be doing nothing until the I/O is complete. In this case, the device driver can run the whole I/O operation in the caller's context and return immediately. Message passing and task switch overhead is eliminated. When the BeginIO function returns with the IOF_QUICK bit still set, it means that the I/O operation is complete.
If the device driver sees the IOF_QUICK flag set but cannot perform the I/O processing inline, it can simply clear the flag and return the I/O request with ReplyMsg as usual.
The BeginIO function operates on the command and parameters in the I/O request, and sets the "io_Error" field to indicate the result. The exec I/O functions return the value of this field to the caller; BeginIO itself does not return a value. "io_Error" set to zero means that no error has occurred.
2.6. THE ABORTIO FUNCTION
Some device driver operations, such as waiting for a timeout or input on a serial port, may need to be aborted before they complete. The AbortIO function is provided for this. The device driver is entered through its AbortIO entry point with the address of the I/O request to be aborted in A1, and the device node pointer in A6. If the device driver determines that the I/O request is indeed in progress and can successfully abort it, it returns zero, otherwise it returns a non-zero error code.
A successfully aborted I/O request is returned by the normal method, i.e. ReplyMsg. The "io_Error" field should indicate that it did not complete normally.
2.7. EXEC I/O FUNCTIONS
The following primitives are provided for communicating with device drivers. It is assumed that the driver has been opened with OpenDevice and an initialized I/O request exists.
This function calls the BeginIO entry point in the device driver with IOF_QUICK clear. This means that the device driver should return the I/O request with ReplyMsg.
This function calls the BeginIO entry point in the device driver with IOF_QUICK set. If the device driver leaves IOF_QUICK set, it returns to the caller immediately. The return value is the extended value of the "io_Error" field in the I/O request. If the IOF_QUICK bit is cleared, it falls through to WaitIO.
This function waits for an I/O request to complete. If the I/O request has the IOF_QUICK flag set, it cannot possibly be in progress, so it returns immediately. Otherwise, the I/O request will be returned with ReplyMsg, and the function proceeds as follows:
Finally, it returns "io_Error" from the I/O request, extended to a longword, as usual.
This function checks if the indicated I/O request is complete. It is considered complete if its IOF_QUICK bit is set, or if its type is NT_REPLYMSG. In this case, the function returns the address of the I/O request. If the I/O request is not complete, it returns zero. This function does not dequeue the I/O request from the reply port.
This function calls the AbortIO entry point in the device driver, as discussed earlier.
2.8. CALLING BEGINIO DIRECTLY
There is one operation which DoIO and SendIO cannot handle, and that is sending an I/O request with the IOF_QUICK flag set, but not waiting for it to complete. That is "run this as quickly as possible but if it's going to take a while, don't wait for it". For this operation, the user must set IOF_QUICK manually, then call the device driver directly through its BeginIO entry point. The following C library function will do the latter:
Since WaitIO and CheckIO know about the IOF_QUICK flag, I/O requests submitted this way can be processed by WaitIO and CheckIO as usual.
2.8. SYNCHRONOUS I/O
Synchronous I/O is done when the caller does not wish to continue until the I/O operation is complete. In this case, the caller just sets up the I/O request and does a DoIO on it. When DoIO returns, the I/O is complete.
2.9. ASYNCHRONOUS I/O
Asynchronous I/O is done when the caller wishes to submit an I/O request and then do other things while it completes. Such I/O is submitted by SendIO or BeginIO as discussed earlier. There are a variety of ways to wait for completion of an asynchronous I/O request; several are discussed below.
2.9.1. WAITING FOR A SPECIFIC I/O REQUEST
Waiting for just one specific I/O request is done with the WaitIO function. The function will not return until that particular I/O request is completed.
2.9.2. WAITING ON A SPECIFIC REPLY PORT
If a number of I/O requests are outstanding and all will arrive at the same reply port, then the following can be used:
2.9.3. GENERAL CASE
Often, a program will be waiting for one of a number of events to occur, and the only thing these events have in common is that they will set a signal. Then the program must get the signal bits from all the I/O reply ports, merge them with the other signal bits to wait for, and do a Wait on the result. When the Wait returns with a signal bit set which corresponds to an I/O reply port, then a GetMsg can be attempted on that port to see if an I/O operation has completed. In this manner, I/O completions can be handled together with other events in any order.
3. GENERIC COMMAND AND ERROR NUMBERS
This section lists the command and error numbers which are predefined in the Amiga system for all types of device drivers.
3.1. COMMANDS
The command number is stored in the "io_Command" field of the I/O request. The generic command numbers, as found in the include file "exec/io.h", are as follows:
It is seen that command number zero is invalid, and command numbers greater than 9 are custom defined. The remaining commands are as follows:
3.1.1. CMD_RESET
This resets the device to a known initial state. Pending I/O requests not processed at the time of this command should be returned with an error.
3.1.2. CMD_READ
This requests that "io_Length" items of data be read from location "io_Offset" on the unit, and stored at "io_Data" in the caller's memory. The actual amount of data transferred is returned in "io_Actual". The specifics depend on the device type.
3.1.3. CMD_WRITE
This requests that data be transferred from the caller's memory to the I/O unit. The arguments are the same as for CMD_READ.
3.1.4. CMD_UPDATE
This requests that all buffered, but unwritten data be forced out to the I/O unit. It might write out the track buffer in a disk device, for example.
3.1.5. CMD_CLEAR
This requests that all data buffered by the device for the given unit be invalidated. Thus, for example, it would throw away data waiting in a serial input buffer.
3.1.6. CMD_STOP
This requests that the unit stop processing commands. I/O requests not processed at the time of the CMD_STOP will wait until a CMD_START or CMD_RESET is received or they are aborted.
3.1.7. CMD_START
This requests that the unit clear a CMD_STOP condition and resume processing commands. Only one CMD_START is required, regardless of how many CMD_STOPs have been received.
3.1.8. CMD_FLUSH
This requests that the unit flush all pending commands. All I/O requests queued but not yet processed should be sent back with an error.
3.2. ERROR NUMBERS
The include file "exec/errors.h" lists the following standard error numbers.
4. DISK DEVICE DRIVERS
Real device drivers usually support more commands than those listed in section 3. This section describes the command set used by disk device drivers. This information is needed to write a disk driver compatible with the existing file systems and disk repair programs.
All "normal" disk commands use an I/O request of the structure given earlier. The "extended" trackdisk commands, which use a larger I/O request, are not discussed.
4.1. COMMANDS
The include file "devices/trackdisk.h" lists the following command numbers:
Some of these commands are specific to removeable media and/or the trackdisk.device and need not be supported by a hard disk driver. More on this later. The following sections describe the individual commands.
4.1.1. CMD_READ AND CMD_WRITE
These are generic commands, but the details are specific to the type of device. For disk devices, the "io_Length" and "io_Offset" fields must be an exact multiple of the sector size supported by the device. At present, this is 512 bytes. The "io_Actual" field could conceivably return a different value from "io_Length" if an error stopped the operation part way through.
4.1.2. TD_MOTOR
This command turns the floppy disk motor on and off. "io_Length" should be set to zero to turn it off, or one to turn it on. "io_Actual" will return the previous state of the motor. The motor will turn on automatically as required, but never off again; thus, the user must issue a TD_MOTOR command to turn it off.
4.1.3. TD_SEEK
This command moves the read/write heads to the position indicated in "io_Offset", which must be an exact multiple of the sector size. Seeking is implied in other commands, but this command can be used to "pre-seek" the device if the needed position is known in advance of the read/write operation.
4.1.4. TD_FORMAT
This command takes the same arguments and performs the same operation as CMD_WRITE, with the following two exceptions:
This describes the observed behaviour of the trackdisk.device. The autodoc for "TD_FORMAT" disagrees with this information.
Hard disk drivers typically implement TD_FORMAT as a simple call to CMD_WRITE. This allows the hard disk to be "high level formatted" with the AmigaDOS "format" command.
Some ancient SASI/SCSI disk controller boards have a "format track" command, so a driver targeted specifically at them could implement TD_FORMAT the same way as the trackdisk.device does.
4.1.5. TD_PROTSTATUS
If there is a disk in the drive, this command returns "io_Actual" set to zero if the disk is writeable, nonzero if protected. If there is no disk, the I/O request returns with the "io_Error" set to "TDERR_DiskChanged".
4.1.6. TD_RAWREAD AND TD_RAWWRITE
These commands read and write whole tracks of raw MFM data on the trackdisk.device. Refer to the autodocs for detailed information.
4.1.7. TD_GETDRIVETYPE
This command is trackdisk.device specific, and returns the type of disk drive in "io_Actual". This is one of the following:
4.1.8. TD_GETNUMTRACKS
This command is trackdisk.device specific, and returns the number of tracks on the disk unit in "io_Actual".
4.1.9. TD_CHANGESTATE
This command checks to see if there is a disk in a removable-media drive. It returns "io_Actual" set to zero if there is, nonzero if there is not.
4.1.10. OTHER COMMANDS
The remaining commands are not described because I am not sufficiently familiar with them at this time. The reader should refer to the appropriate autodocs. In some cases, experimentation with the trackdisk.device will be needed to get all the details.
4.2. ERROR NUMBERS
The following error numbers are listed in the include file "devices/trackdis.h".
4.3. SCSIDIRECT PROTOCOL
Most SCSI device drivers support an additional command to issue a generic SCSI command. This is described in detail in the include file "devices/scsidisk.h".
4.4. A MINIMAL DISK COMMAND SUBSET
It is clear that to implement a full Amiga disk driver supporting removeable media is a considerable task, and I don't even pretend to know all that is required. But if all you want is a simple hard disk driver, a very small subset of the commands is sufficient. To begin with, Expunge can be stubbed out to always return zero, indicating a delayed expunge which will never get done. AbortIO can be stubbed out to always return a nonzero result, indicating that it failed. Unit structures can be done away with; you can store anything in the "io_Unit" field of the I/O request. In my own device driver I just store the SCSI device number and a few flags there. You can just return I/O error #20 (general catch-all) for anything that went wrong, except possibly unimplemented commands. Quick I/O need not be done; just always clear IOF_QUICK and forget about it. Send all I/O requests to a single internal task for processing. Finally, the commands can be implemented as follows:
The resulting driver works perfectly with fast and slow file systems, and all the disk edit/repair utilities I've tried it with. If you are bringing up a hard disk from scratch, you can always get a hack driver to work, then write a "proper" one with the hard disk running.
5. REFERENCES
- 1.3 Include files
- 1.2 Autodocs
- Commodore's example disk device driver, from the DevCon 88 disks.
- Exec 1.2 disassembly, by myself. Get it from Fred Fish disk 188.
6. REVISION HISTORY
0.10 (90/05/20) Initial version 0.11 (90/05/21) Proofread, minor updates, nicer format 0.12 (90/05/21) Corrected a few typos