Lecture 10, Sequential Devices

Device independence, serial ports and tape drives

Part of the notes for 22C:112, Operating Systems
by Douglas W. Jones
THE UNIVERSITY OF IOWA Department of Computer Science

 

Problems Posed by Sequential Input/Output Devices

The simple computer system we have been discussing could have a number of input-output devices, most of which can be classified as sequential devices, that is, devices where data is read or written as a sequence of bytes, and where the chronological order of input-output operations or the position of the data on a linear recording medium dominates the structure of the data.

Nowdays, of course, most of our input-output is directed to files on random access devices such as disks or flash memory, but most of our use of those devices ignores their potential for random access and gives us a sequential view of the data in each file.

Support for sequential devices is simple only for the most basic devices, and only if performance is not a primary consideration. If the system attempts to isolate the user from the details of the particular device being used, as with most systems, further complexity is introduced. This chapter describes the hardware and software typically used to support two important classes of sequential devices, and it describes a framework for implementing device independent input-output.

File Variable Implementation -- Polymorphism

We have assumed that any open files f could be operated on by operations such as put(f,c) or f.put(c), with the particular form depending on whether an object oriented or a procedural worldview is adopted. This requires that there be put and get operations applicable to each device in the system that have an identical user interface.

As a first draft, we might just write functions like get-keyboard() and get-tape1(), one function to read each device, where we approximate polymorphism by giving all functions identical return types and identical parameter lists. Having done this, reconfiguring a program to run on a different device involves making a global substitution, for example, changing the text keyboard to tape1 everywhere in the program and then recompiling. This is better than nothing, but it is hardly convenient.

A next step might be to code a universal get routine, taking a device number as a parameter and using a switch/select/case statement to call the appropriate device specific routine. This is the direciton systems were evolving in the early 1960's when the first glimmerings of object-oriented thinking began to emerge in the community of computer programmers.

The next step is to move the device specific storage areas from the statically allocated memory of the device-specific input-output routines to the file variable. This allows, for example, one read-tape routine to be used to read form any tape. A mature interface using this approach might be constructed using an oper-file description variable as described in Figure 1.

 

In Pascal
type deviceclass = (keyboard, display, tape ... );

     { device specific data records, one per device class }
     keyboardrec = ...
     displayrec = ...
     taperec = ...

     filevariable = record case class: deviceclass of
                         keyboard: ( keyfile: keyboardrec);
                          display: (dispfile: displayrec);
                             tape: (tapefile: taperec);
                                      .
                                      .
                                      .
                    end { record case }
In C
enum deviceclass {keyboard, display, tape ... };

/* device specific structures, one per device class */
struct keyboardrec { ...
struct displayrec { ...
struct taperec { ...

struct filevariable {
     enum deviceclass class;
     union {
          struct keyboardrec keyfile;
          struct displayrec dispfile;
          struct taperec tapefile;
     } variant;
};

Figure 1. A data structure for file variables.

Whatever programming language is involved, the data structure we need is polymorphic -- that is, it involves a different representation for each device class. In Pascal or Ada, this is accomplished with variant records, while in C and C++, it can be accomplished with a union. Given the basic data structure shown in Figure 1, each of the device independent input-output routines can be reduced to a case/select/switch statement which passes appropriate parameters to the appropriate device dependent input-output routine, as illustrated in Figure 2.

In Pascal
procedure put( var f: filevariable; ch: char );
begin
     case f.class of
          keyboard:  putkey( f.keyfile,  ch );
           display: putdisp( f.dispfile, ch );
              tape: puttape( f.tapefile, ch );
                            .
                            .
                            .
     end { case };
end { write };
In C
void put( struct filevariable * f, char c )
{
     switch (f->class) {
     case keyboard: putkey( &(f->variant.keyfile), c ); break;
     case display:  putdisp( &(f->variant.dispfile), c ); break;
     case tape:     puttape( &(f->variant.tapefile), c ); break;
                            .
                            .
                            .
     }
}

Figure 2. The device independent put routine.

Following the pattern suggested in Figures 1 and 2, each device class must provide a class specific record type along with class specific input-output routines corresponding directly to the device independent input-output routines suggested above. The device specific routines for a device make up the device driver for that device.

Although this implementation of file variables is quite effective, it imposes an avoidable overhead on each call to an input-output service routine; the search for a way to avoid this overhead led to the first full implementation of the object oriented model of polymorphism in the mid 1960's.

The key idea in object-oriented polymorphism is that each object, or in C and Pascal terminology, each record or structure, begins with pointers to the functions or procedures that are used to implement the operations on that object. Thus, a call to the read operation on an open file is done by calling the function pointed to by the read pointer of the record describing that open file. This way, a read operation on one file will be automatically done by the function that matches the device used for that file, without requiring any complex decoding operations.

Object oriented languages such as C++ and Java implement all polymorphic operations in essentially this way, although there are alternatives offering different space-time tradeoffs. Some non object-oriented languages, for example, Pascal and Ada, forbid pointers to functions. Languages such as C provide no built-in support for object orientation, but because they allow pointers to functions, they can be used to show how object oriented languages implement polymorphism without descending to the assembly language level. Figure 3 illustrates this:

/* the definition of the type filevariable */
struct filevariable {
     /* pointers to device specific routines */
     void (* rewind)( struct filevariable * f );
     char (* get)( struct filevariable * f );
     void (* put)( struct filevariable * f, char c );
          .
	  .   /* pointers for other operations from Figure 8.4 */
	  .
};

/* functions implementing access methods */
#define REWIND(f) (*(f->rewind))(f)
#define GET(f)    (*(f->get))(f)
#define PUT(f,c)  (*(f->put))(f,c)
          .
	  .   /* defines for other operations from Figure 8.4 */
	  .

Figure 3. C code for file objects.

The declaration char (*p)() in C declares p to be a pointer to a function returning a character; and the call (*p)() calls the functiona pointed to by p. The defines following the structure declaration in Figure 3 hide this arcane notation, allowing conventional function call notation to be used for these access functions in the remainder of the program.

It is important to note that each access function for the file object requires a pointer to the structure representing that object to be passed as a parameter to the function. This parameter provides the code of the function with its access to all of the fields of the object itself, including not only the other access functions for the object, but also any data fields.

Note that the polymorphic class implementation described above for C was first exploited by J. E. Stoy and C. Strachey in their OS6 operating system, a system written in BCPL (an ancestor of C) and developed in the late 1960's. See the suggested readings for details.

An Example Device

The problem of writing a device driver to handle a serial output device will be used to illustrate the basic structure of typical device drivers. The serial port could be connected to a printer, to another computer running a terminal emulation program, or even to an antique "dumb terminal" consisting only of a keyboard and display screen, without any local processing power. It is worth noting that many modern computers still support serial ports, for example, the Raspberry Pi, introduced in 2012, offers a serial port as an alternative use for two of its GPIO pins.

In order to discuss the structure of such a driver, it will be necessary to first discuss the hardware interface to the serial port, since it is the device driver's responsibility to manage this interface. When a display or printer is connected through a serial port, data to be output is sent one byte at a time, and within each byte, each bit is sent sequentially. It is the responsibility of the device connected to the serial port to interpret the various bytes as printable character codes or control codes; the input-output driver software itself need not be aware of the two dimensional nature of the pages being printed or of the display screen.

In general, the devices attached to serial ports include small microprocessors to interprets the control codes and printable characters sent over the serial cable. This was not always the case. Serial printer hardware predates the computer age by many years. Émile Baudot developed the first serial binary data format for printing telegraphy in 1874, and by the 1920's, E. E. Kleinschmidt had reduced these ideas to practical form, introducing a line of printing telegraph equipment under the trademark Teletype. Kleinschmidt's model 15 Teletype operated at only 75 baud (bits per second) and used a 5-bit code, but the asynchronous serial interface hardware of many modern computers is fully capable of sending output to one of these old machines.

The input-output hardware of a typical computer is controlled through a set of device registers. Each input-output interface is typically associated with at least two of these registers. The details of how these registers are accessed varies from one computer system to another. On some systems, the device registers are accessed as if they were memory locations, while on others, special machine instructions are required to access device registers. As a result, even if the rest of an operating system is written in a high level language, the code to actually manipulate device registers is frequently written in assembly language.

On the IBM PC family of computers, special machine instructions called IN and OUT are used to move data between the device registers and the registers inside the CPU. C or C++ programmers usually access the input-output registers through the routines v=inp(a) and outp(a,v), where a is the input-output register number and v is a one-byte balue read from or written to that register. On the ARM architecture that is used by the Raspberry Pi and many cellphones, the input-output registers of each device are accessed as special memory locations.

A serial output device typically has at least two input-output registers, one for the data being output to the device and one or more to control options in the interface and to report on status from the interface. For example, the IBM PC and compatables, the COM1 port (serial port 1) uses device registers 3F816 through 3FF16 as shown in Figure 4:

3F8 COM1DATA Transmit data register, if used for output
3F8 COM1DATA Receive data register, if used for input
These two one-byte registers share the same device address, and are distinguished by the operation used to access them. The transmit data register is write-only; anything written there will be sent out the serial port. The receive data register is read-only; reading it reports the most recent character received as input on the serial port.

 

3F9 COM1IER Interrupt enable register
The bits of this one-byte read-write register determine which conditions detected by the interface will cause interrupts. If this register is all zero, no interrupt requests will come from this device. Interrupts are discussed later in this text.

 

3FA COM1IIR Interrupt identification register
After the serial port hardware requests an interrupt, the bits of this one-byte read-only register explain the cause. Interrupts are discussed later in this text.

 

3FB COM1LCR Line control register
The bits of this one-byte read-write register determine the data format used on this serial line:
      _______________
     |_|_|_____|_|___|
      | | | | | | | |
      | | | | | | 0 0 -- 5 bits per data word
      | | | | | | 0 1 -- 6 bits      "
      | | | | | | 1 0 -- 7 bits      "
      | | | | | | 1 1 -- 8 bits      "
      | | | | | 0 -- 1 stop bit after each word
      | | | | | 1 -- 2 stop bits     "
      | | x x 0 -- no parity
      | | 0 0 1 -- odd parity
      | | 0 1 1 -- even parity
      | | 1 0 1 -- parity bit always 0
      | | 1 1 1 -- parity bit always 1
      | 0 -- normal
      | 1 -- force output line to break state
      0 -- normal
      1 -- allow baud rate divisor to be set

3F8 COM1BRDL Baud rate divisor (low byte)
3F9 COM1BRDH Baud rate divisor (high byte)
If the high bit of the line control register is set, the input-output addresses normally used to access the data and interrupt-enable registers are instead connected to the 16-bit baud-rate divisor register. This takes the following values:
      baud rate     divisor (hex)    baud rate     divisor (hex)
          50          0900             2400          0030
         110          0417             4800          0018
         150          0300             9600          000C
         300          0180            19200          0006
         600          00C0            38400          0003
        1200          0060            57600          0002
        1800          0040           115200          0001
        2000          003A

3FC COM1MCR Modem Control Register
This one-byte read-write register controls some auxiliary output pins of the serial port connector that are used for modem control, along with a few other more obscure functions that are not described here.
      _______________
     |___________|_|_|
                  | |
                  | 0 -- reset Data Terminal Ready
DTR               | 1 -- set Data Terminal Ready (normal state)
                  0 -- reset Request To Send
RTS               1 -- set Request To Send (normal prior to output)
Unused bits of this register should be set to zero!

3FD COM1LSR Line Status Register
This one-byte read-only register holds the status of the serial port.
      _______________
     |_|_|_|_______|_|
        | | | | | | |
        | | | | | | 0 -- receiver data not ready
RxRD    | | | | | | 1 -- receiver data ready to be read
        | | 0 0 0 0 -- no error on input
RxOE    | | x x x 1 -- overrun error (new data arrived before old data read)
RxPE    | | x x 1 x -- parity error
RxFE    | | x 1 x x -- framing error (missing stop bits at end of data)
RxBR    | | 1 x x x -- received data line in break state
        | 0 -- transmit data register not ready (it still holds data)
TxDE    | 1 -- transmit data register ready (it no longer holds data)
        0 -- transmitter busy
TxID    1 -- transmitter idle
3FE COM1MSR Modem Status Register
This one-byte read-only register holds the status of some auxiliary input pins of the serial port connector that are used for modem control, along with a few other more obscure functions that are not documented here.
      _______________
     |_|_|_|_|_______|
      | | | |
      | | | 0 -- Clear To Send is false
CTS   | | | 1 -- Clear To Send is true
      | | 0 -- Data Set Ready is false
DSR   | | 1 -- Data Set Ready is true
      | 0 -- Ring Indicator is false
RI    | 1 -- Ring Indicator is true (ring signal on phone line)
      0 -- Carrier Detect is false
CD    1 -- Carrier Detect is true (local modem has detected remote modem)
3FF COM1SCR Scratch Register
This one-byte read-write register is present in the interface but otherwise unused by the hardware; it can be used as a variable by the interface software!

Figure 4. The IBM PC COM1 serial port specification, somewhat simplified.

Data transmitted over an asynchronous serial port such as the COM port described in Figure 4 contains more than just the data bits. Each byte transmitted is prefixed with a start bit, and each byte is followed by at least one stop bit. These framing bits allow the receiver to recognize data on the line and distinguish it from times that the line is not in use. In addition, each byte of data may include a parity bit. Parity bits are used for error detection; typically, the exclusive-or of the data bits is used to compute the parity bit. There are, unfortunately, two common ways of storing the parity bit, even parity, where the number of one bits in the byte, including the parity bit, is always even, and odd parity, where the number of one bits in the byte, including the parity bit, is always odd.

An Example Output Driver

If we assume that the serial port described in Figure 4 has already been initialized, setting the baud rate, data format and modem controls appropriately to the device connected to the port, the code shown in Figure 5 will suffice to send data to the device:

/* the input-output register numbers */
#define COM1DATA 0x3F8
#define COM1LSR  0x3FD

/* transmit data register empty bit in status register */
#define TxDE 0x20

/* test for transmit data register empty */
int com1busy()
{
     return (inp( COM1LSR ) & TxDE) == 0;
}

/* output one character to the display device */
void putcom1( char c )
{
     while (com1busy()) /* empty loop body */;
     outp( COM1DATA, c );
}

Figure 5. Crude C code to output a byte to the COM1 port on an IBM PC.

The code given in Figure 5 illustrates a very common coding pattern for primitive input-output routines. Prior to attempting to move data, the code waits for the device to be ready (or inversely, it waits for the device to be not busy), and then it transfers the data to the device. The crudest way to wait for a condition is to go into a tight loop doing nothing in the loop body; the change in the device status will terminate this loop.

The loop awaiting a change of status in a device is called a polling loop. Note that the verb "to poll" means to ask a question; from this, we get common English terms such as "public opinion polling" and "polling places," terms that refer to asking questions to people, either to determine the sentement of the population or to administer an election. Here, we use the term to refer to the software repeatedly asking the hardware "are you ready yet."

A computer system may have multiple output devices; not only is it likely that there will be a memory mapped primary display screen, but there may be multiple asynchronous ports connected to display devices. The classic standard IBM PC configuration included COM1 and COM2, two identical asynchronous ports, and there are standard input-output register numbers reserved for COM3 and COM4. Other than the change in input-output register numbers, COM1 through COM4 have identical interfaces, so any well designed system will a single set of input-output routines to access all of these.

We will do this by including the input-output register numbers for the device as fields of the file object referring to that device. Consider, for example, extending the data structures given in Figure 3 to create a driver for the IBM PC COM port as outlined in Figure 6.

/* the definition of the type comportvariable based on filevariable */
struct comportvariable {
     /* pointers to device specific routines */
     void (* rewind)( struct filevariable * f );
     char (* get)( struct filevariable * f );
     void (* put)( struct filevariable * f, char c );
          .
	  .   /* pointers to routines for other operations */
	  .

     /* device specific information */
     int comdata;   /* input-output register number of data register */
     int comstatus; /* input-output register number of status register */
};

/* functions implementing access methods */
void putcom( struct filevariable * f, char c )
{
     struct comportvariable * cp = (struct comportvariable *) f;

     while ((inp( cp->comstatus ) & TxDE) == 0) /* empty loop body */;
     outp( cp->comdata, c );
}
          .
	  .   /* other functions */
	  .

/* function to open communications port n */
struct filevariable * comopen( int n )
{
     /* register numbers of COM1 to COM4 */
     static int comports[] = { 0, 0x3F8, 0x2F8, 0x3E8, 0x2E8 };

     /* the new open file object */
     struct comportvariable * cp;
     cp = malloc( sizeof(struct comportvariable) );

     /* initializer */
     cp->put = putcom;
          .
	  .   /* initialize other access function pointers */
	  .
     cp->comdata = comports[n];
     cp->comstatus = cp->comdata + 5

     /* give the caller the initialized instance */
     return (struct filevariable *) cp;
}

Figure 6. An input-output driver for a PC COM port.

The code given in Figure 6 uses casting, a feature of the C family of languages, to force the compiler to allow variable of type (struct filevariable *) and (struct comportvariable *) to be interchanged.

In C, C++ and related languages, the notation (t)e is called a cast. This forces the value of the expression e to be interpreted as a value of type t. In C, casting has several different functions, depending on the context. When casting is used to convert between pointer types, as we have done here, the pointer resulting from the casting operation refers to the exact same memory location as the original, but the casting operator tells the compiler that the resulting pointer has a new type.

In the outside world, all users refer to open files using the type (struct filevariable *), but within the code to acces one of the asynchronous communication ports, it is necessary to override the type checking and use the special fields declared in the comportvariable structure. In an object oriented language such as C++ or Java, we would say that comportvariable is a subclass of filevariable, and that filevariable, because it contains no implementations of any access functions, is a virtual class or an interface specificaiton, while comportvariable, because it includes methods and an initializer (the opencomport routine) would be called a concrete class.

The driver shown in Figure 6 does not include code for the block or line oriented output routines gets and puts that are natural consequences of the suggestions outlined earlier; these can easily be written using the put service and a simple loop. This is not wise, however, because for some devices, the natural unit of data transfer is the line or the block of data. For shose devices, puts might be the natural basic operation, and put should be implemented in terms of it. Therefore, each device should decide whether the character-at-a-time or the block-at-a-time is primitive and implement the other in terms of that primitive. This requires that each device provide both sets of operations.

The communications ports on an IBM PC also allow input, so we should write a getcom routine and add corresponding putscom and getscom routines. We also need a rewindcom routine, not because there is any meaningful rewind operation on any kind of asynchronout communications port, but rather, because our standard interface to open files allows this operation, and we must provide implementations for all of the operations, whether or not they are meaningful. Perhaps the rewindcom operation should just raise an exception if it is used on a device where it is undefined, or alternatively, do nothing.

Systems that supports multiple serial input-output ports typically do so using devices called serial input-output multiplexors. Such multiplexors are extremely common on the machines used by internet service providers, where one server machine may handle an entire modem bank. (Yes, there are still people who depend on dial-up modems.) When this is done, there is typically a device register which is used for port selection. Only after the port select register has been set to select a particular port can the various status and data registers be used to communicate with that port. Modern interfaces such as USB follow a similar model.

Display Screen Interfaces

Memory mapped display interfaces involve the use of a special direct-memory-access peripheral processor (the display controller) which continuously scans an array in memory called the memory image of the display in order to generate a video signal for the display. For text-only display screens, the memory image is a two dimensional array of characters, typically 24 rows by 80 columns. (The 80 column line length is a legacy from the days when punched cards were widely used for text storage; the most widely used card format stored 80 characters per card.)

For black-and-white graphics displays, the memory image is usually a two dimensional array of bits, one bit per dot on the screen, with 8 bits packed in each byte; such a display is called a bit-mapped display. For color graphics displays, the memory image is usually a two-dimensional array of pixels, and we refer to the display as a pixel-mapped display. Low resolution color displays usually use one byte per pixel, while high resolution color displays may use 16, 24 or 32 bits per pixel. For example, a high resolution color display might use 10 bits each for the red, green and blue components of the display, wasting two bits of each 32 bit pixel.

The software needed to support a memory mapped graphics display interface is quite complex, even if all that is to be done is to plot text on the display. The reason is that, to display a character, the dot pattern which represents that character must be moved to the appropriate place on the screen. Line drawing, textures, and windows, make memory mapped graphics displays attractive to users, but they add considerable complexity to the software. Memory mapped text displays are, by comparison, quite easy to support, and all of the operations required to support such displays also must be performed in supporting a graphics display.

While many IBM PC compatables contain hardware that will operate as a text-only memory mapped display, this hardware is used only during startup, and once the system is up and running, the display switches to bit-mapped or pixel-mapped operation. Macintosh computers appear to have entirely abandoned text-only display technology from the start and rely entirely on a bit-mapped or pixel-mapped display plus software, some of which runs on a graphics coprocessor.

Fully general bit-mapped or pixel-mapped display software is quite complex! There is usually one basic data type used for characters, cursors and icons; a good name for such a data type is the glyph; a glyph might be represented by a bitmap or a pixmap (pixel map), plus 4 to 6 integers, the width of the glyph, the height of the glyph, the X-location of the origin of the glyph, the Y-location of the origin, and possibly the X and Y displacements to use when displaying the glyph. The basic operation used for displaying cursors, icons and characters would logically be "display this glyph with its origin at this X and Y coordinate on the screen."

Early designs for the glyph data type frequently ignored the question of the origin, assuming that all glyphs would have their origin at one corner, for example, the upper left. Later, it was realized that some glyphs have their logical origin somewhere else, for example, the center of the glyph is the logical origin for an X shaped cursor, while the logical origin for a character in the Roman alphabet is at the left edge of the baseline of the character.

Early designs tended to assume that characters should always be displayed from left to right, with the cursor moving by the width of the glyph when it is displayed. Not everyone in the world wants to use the Roman alphabet, though, so modern machines allow for right to left and left to right display by distinguishing between the width of the glyph and the amount by which the cursor is to be displaced when the glyph is displayed. In addition to allowing for right-to-left languages such as Hebrew and Arabic, this allows for accent marks (characters that sit over or under the previous character without moving the cursor when they are typed).

A font, that is, a set of rules for displaying text on the screen, must then be an array of glyphs, indexed by character, and the fundimental operation for displaying text on the screen must operate in terms of a current font and a current location (the cursor position). Displaying a character must display the glyph for that character at the current position and then advance the location by the displacement for that character.

Newer display software for memory-mapped displays is even more complex, with each letter described by its outline and rules for painting that outline. This allows fonts to be scaled (poorly), and it allows the use of anti-aliasing to reduce the likelihood that the user will notice that the display is pixelized.

A full discussion of memory mapped displays cannot be given here; an entire course could be devoted to computer typography and font rendering algorithms, and we have not yet covered a sufficient amount of material on direct-memory-access device interfaces to treat the low level interface to such bit-mapped and pixel-mapped display devices.

Keyboard Input Drivers

The driver for a typical keyboard is noticeably more complex than that for a character oriented display device. In the first place, most modern keyboards are USB devices. The USB, or Universal Serial Bus, is a network device, where every USB device includes network interface software allowing multiple USB devices to share one USB port through a tree of USB hubs. We need to talk about something simpler than this for an introductory discussion!

Another reason for the complexity of keyboard drivers is the need to echo what is typed to the display screen. Except on very old remote terminal systems or on systems where special hardware does most of the input-output driver's work, echoing is the responsibility of software. In modern systems, the window manager usually handles echoing, turning off what is in the driver, but the old driver model is still there for when the system is operated with the window manager turned off.

With echoing turned on, when a user hits a key on the keyboard, the display of the corresponding character on the screen is the result of the interaction of system software and application software.

On really old remote terminals, it was common to run the communications lines between the terminal and the computer in half-duplex mode, that is, in a mode where data could only go one way at a time. This was necessary because hard telegraph lines and early modems for use on telephone lines could not handle bidirectional data. Thus, the terminal itself had to echo of keyboard input to its local display. On remote terminal systems built since the early 1970's, communication has usually been done in a full-duplex mode, where data can go both ways at the same time; thus, what is typed on a terminal goes all the way to the computer, even if it is on the other side of the world, and only after it has been input is it sent back to the display screen. With modern personal computers, the story remains essentially the same when applications are run remotely.

Another source of complexity in keyboard drivers is the line-buffering software. When a line of input is requested, all reasonable systems allow users to erase typing mistakes; sometimes word-erase and line-erase functions are provided. In the extreme case, some systems allow a line of input to be edited arbitrarily, for example, by deleting a word from the middle of the line without requiring that the entire line be retyped.

In order to allow a full discussion of keyboard input, we need a simple keyboard interface. Modern PC keyboards are quite complex, with a local microprocessor in the keyboard and a bidirectional network connection, for example, the USB connection, linking the microprocessor in the keyboard with the host computer. This is too complex for this discussion, so instead, we will consider a system with a serial keyboard of the type designed to plug into a standard serial port.

The serial communications ports of almost all computers are bidirectional. Writing data to the data register of one of the COM ports causes that data to be transmitted. Reading data from the very same data register does not give the data being transmitted, but instead, it gives the most recent character received, as described for the Transmit Data Register and Receive Data Register of the PC COM port in Figure 4. The status register of the COM port contains a status bit indicating that the receiver data register contains data ready to be read, RxRD; this is distinct from the status bit indicating that the transmit data register is empty, ready to be written, TxDE. (The abbreviations Tx for transmit and Rx for receive are old.)

The basic code to read a character from the COM1 port follows a pattern very similar to that used in Figure 5 to write data to the same port. This is given in Figure 7

/* the input-output register numbers */
#define COM1DATA 0x3F8
#define COM1LSR 0x3FD

/* transmit buffer empty bit in status register */
#define RxRD 0x01

/* test for receive buffer not ready */
int com1idle()
{
     return (inp( COM1LSR ) & RxRD) == 0;
}

/* input one character to the keyboard device */
char getcom1()
{
     while (com1idle()) /* empty loop body */;
     return inp( COM1DATA );
}

Figure 7. Crude C code to input a byte from the COM1 port on an IBM PC.

The code shown in Figure 7 is significantly worse than the code from Figure 5 that it parallels! This is because the receiver for the communications line may detect many errors, for example, parity errors. Most of the bits of the status register are reserved for reporting these errors, and the software should check these to determine whether the data received is of any value!

A complete keyboard driver on a modern machine must include a low-level interface for character-at-a-time interaction, without echoing, and it must include a high-level interface for text input that echoes characters as they are typed and supports erase functions. Figure 8 presents such a driver, based on the framework from Figure 6.

/* added functions implementing access methods */

char getcom( struct filevariable * f )
{
     struct comportvariable * cp = (struct comportvariable *) f;

     while ((inp( cp->comstatus ) & RxRD) == 0) /* empty loop body */;
     return inp( cp->comdata );
}

void getscom( struct filevariable * f, char buf[], int len );
{
     int p = 0;

     do {
          char ch = f->get(f);
          if (ch >= ' ') { /* character is printable */
               if (p < len) { /* space is available in buffer */
                    buf[p] = ch;      /* put it in the buffer */
                    p = p + 1;
                    f->put(f, ch);  /* echo it */
               }
          } else if (ch == '\b') { /* character is backspace */
               if (p > 0) then begin
                    p = p - 1;
                    f->put(f, '\b');  /* erase a character */
                    f->put(f, ' ');
                    f->put(f, '\b');
               }
          }
     } while ((ch != '\n') && (ch != '\r'));
     if (p < len) { /* space is available in buffer */
	  buf[p] = '\0';    /* put terminator in buffer */
     }
     f->put(f, '\r');
     f->put(f, '\n');
}
Figure 8. A keyboard device driver.

The call getscom(f,b,l), using the routine defined in Figure 8 reads one line from the communications port f into the buffer b, reading no more than l characters. If fewer than l characters are read, a null will be appended to the buffer. This convention of using a null to mark the end of a string is typical of Unix and C; it would be equally appropriate to return the number of characters read, or to return an object of type string, with the length of the string encoded in a manner that depends on the implementation of the string class.

The code of getscom given in Figure 8 assumes several things that may or may not be appropriate! First, it assumes that any echoing of input should be to the same COM port that the input is being read from. This is apporpirate if the user is accessing the computer using a dial-up modem from a remote computer, and it is appropriate if the user is using an old-fashioned dumb terminal, but it would not be appropriate for a user of a serial keyboard who wanted the input to echo on a bitmapped display!

The second assumption made in the code in Figure 8 is that successive characters can be erased from the display screen by outputting the string "\b \b" (backspace space backspace). This is not true for ink on paper display devices such as old fashioned Teletype printers, nor is it true for displays supporting variable-width or proportionally spaced fonts. It is true for the classic "dumb terminals" that dominated the computer market from around 1970 to 1980 when Unix was being developed, and it is true for software on modern PCs that emulates such terminals. All of these use a fixed-width fonts and assert the rule that outputting a space erases the previously displayed material on that part of the screen.

An important design decision was made in writing the keyboard driver shown in Figure 8. This involves how echoing is controlled. Here, the decision was that getscom will echo, but that getcom will not. Thus, software using getcom may do its own echoing but need not do so, for example, when reading a password or treating all keys on the keyboard as function keys. Furthermore, getscom only echoes those characters that fit in the buffer.

The designers of Unix took a different approach; with Unix, extra status information is associated with each COM port indicating whether input to that port should echo. The stty shell command or the ioctl operation on open files attached to COM ports set this status information and other special device characteristics such as data rate, character size and parity. Thus, "stty /dev/com1 9600 echo" sets the device named com1 to run at 9600 baud and to echo all input to the display.

Some systems allow use of an echo table and terminator table for keyboard input. The echo table specifies, for each character in the character set, whether or not that character is to be echoed when typed; the terminator table specifies, for each character, whether or not that character is to be treated as an end of line character when typed. This approach allows a very flexible approach to input, although most applications rarely need this flexibility.

Finally, on systems with super and subscript capability in their display devices, and on those with the ability to overstrike two characters, it is useful to have the getscom routine convert input to a canonical form. Thus, if SUP and SUB are the control codes for super and subscript, and the user types "a SUP SUB SUP b", the input buffer should contain only "a SUP b". Similarly, if the user overstrikes O with +, the input buffer should contain the same thing as it contains when the user overstrikes + with O, since the order in which the characters are overstruck should not have any impact on how the overstruck combination looks once it is printed. One approach to this is to have the input routine alphabetize the overstruck characters, so that, no matter what order they are overstruck, they will occur in ascending numerical order, for example "+ BS O" for the example used here.

Magnetic Tape Hardware

If we assume magnetic tape hardware for our file system, we place the entire burden of file management on the human user. This allows us to discuss many details of system structure without introducing the complexity of disk drives and the automated file systems we use to store multiple files on one device.

Perhaps the simplest approach to this is to store our files on audio cassette tapes. Curiously, in the early days of personal computers, when floppy disks were still exotic devices, this was surprisingly common. Some users directly connected audio cassette recorders to RS-232 serial communication ports, using the transmit data output as the audio signal in to the recorder and using the audio out from the recorder as the source of the receive data input. This only works at data rates between around 300 baud and 19200 baud, and it was certainly never intended to work! The key thing to note is that audio tape recorders offer reasonable performance between 20 and 20000 Hz, and one baud is, in the simplest approximations, 1/2 Hz, so the given range of baud rates is safely within the audio range.

A more practical interface to an audio tape drive will offer software control of the play, pause, record and rewind buttons, but it is worth noting that many early personal computer systems of the mid 1970's required the user to press the play and rewind buttons at the right times. If someone wanted to, they could probably rig the two modem control outputs of the serial port to switch between the 4 basic operating modes of a tape drive (doing nothing, playing, recording and rewinding), but we will not go into this here.

Far better magnetic tape drives have been sold for computer systems from the 1950's to the present. Through most of the history of computing, these have been primarily used in high performance systems. In the 1950's, and 1960's, half-inch wide 7-track tape was common. Starting in 1965 through about 1985, 9-track tape was common. A handful of computer centers still maintain drives for these old reel-to-reel tape formats so that data can be recovered from the immense archives of tapes accumulated during the first 30 years of the computer age. Today, some large data centers use 8mm video tape cassettes or digital audio tape cassettes. Frequently, jukebox mechanisms holding thousands of such tapes are used for automated backup and on-line archival data storage. The capacity of one such jukebox can easily reach hundreds of terabytes, with a mechanism the size of a small office.

The interface to the system software presented by a high performance tape drive is quite different from the asynchronous communication port interface described above. There are two reasons for this: First, decent tape drives designed for use with computers typically transfer data at a relatively high speed, thus demanding direct memory access; and second, starting or stopping a tape drive cannot be done instantly, so gaps are typically left separating blocks of data on the tape. Each block must typically be read or written with a single direct-memory-access transfer, and the interblock gap is long enough that if a new transfer is not started immediately after the first ends, the write electronics can be turned off before overwriting the next block. The time it takes to start the tape moving or stop it may be so long that several blocks of data pass under the read heads before the tape is moving fast enough to read or write. Note that blocks of data on tape are sometimes called physical data records (as opposed to the logical records dealt with by user programs), and that the interblock gaps are sometimes called interrecord gaps.

The first problem posed by all high performance tape recording technology is that of data rate. Even the old 7-track tapes from the late 1950's could store 200 to 400 bytes per inch, and could read and write the tape while it was running at hundreds of inches per second. This means that they were moving data at rates of over 20,000 bytes per second in an era when no computers could execute more than 1,000,000 instructions per second. As a result, the computer could only execute about 50 machine instructions between bytes of tape input or output.

Today, video tape technology is widely used with computers. This allows on the order of 20,000,000 bytes per second to be moved to or from tape. If we have a CPU running at 1 gigahertz, it can, just barely, execute 50 machine instructions per byte transferred, but note that the CPU is not the bottleneck! Our modern RAM technology typically runs around 50 nanoseconds per memory access, and this allows exactly 20,000,000 accesses per second! The only reason that the CPU can run faster than this is because the CPU uses cache memory technology to cut down on the number of memory accesses. Clearly, just as the machines of 1960 required special hardware to handle high performance tape interfaces, we still require this today!

This performance problem is usually solved by building a special class of input-output interface called a direct memory access interface. Such an interface contains a small special purpose processor able to read data from a buffer in main memory and send it to a device, and able to read data from the device and store if in a buffer in memory. This direct memory access processor operates in parallel with the central processor, so user programs can continue to execute while the direct-memory-access or DMA operation is performed. The input-output registers for a direct-memory-access device are not used to transfer data to and from the device, but rather, they are used to control and monitor the transfer, while the transfer itself is done directly to main memory.

A typical direct memory access interface for a device such as a tape transport is controlled by the device registers outlined in Figure 9:

address
the address of the next byte (or word) to be read or written by the direct memory access interface processor. This is incremented by the size of the data item as each byte (or word) is read or written. usually, the interface processor tries to do all memory accesses a word at a time to minimize its interference with the CPU's access to memory.

count
the number of bytes remaining to be transferred. This is decremented by the appropriate amount as each byte (or word) is read or written.

control
this contains a field that determines whether data should be read or written. Once the software sets the address and count registers, it can start a data transfer by setting the control register to read or write.

status
this contains a bit that indicates whether a direct memory access transfer is currently in progress. Typically, it also contains a number of other status indicators, so the software can determine, for example, if there is a tape in the drive, if it is moving or stopped, if the most recent read contained any errors, and if the write-protect indicator is set to allow the tape to be written.

Figure 9. The generic interface to a direct memory access device.

The fact that the tape is written one block at a time poses new problems. Bringing the tape up to recording speed is not instantaneous, nor is stopping the tape. As a result, as the software records a series of blocks, there will necessarily be gaps on the tape between the blocks. Once it is recognized that blocks and interblock gaps inevitable, it is natural to extend the recording format to allow these gaps to serve a useful purpose.

Typically, each block recorded on the tape begins with a header, so that the interface hardware can easily identify the start of the next block on the tape when a read command is issued. At the end of each block, it is common to record a trailer containing a checksum of the data in the block. Checksums may be as simple as an arithmetic sum of the bytes in the block, or they may involve fairly complex computations such as the widely used cyclic redundancy check. However a checksum is computed, it allows detection of simple errors in the recorded data when it is read. Some checksum schemes even allow correction of some errors. Typically, the tape drive hardware, or rather, the small microprocessors included in the hardware interface do all of the computations involved in creating the block structure of the tape, including parity and checksum generation and checking.

The desire for error detection explains why, on the classic reel-to-reel computer tape formats, 7 tracks were used for recording data with 6-bit bytes, and 9 tracks were used for recording data with 8-bit bytes. In each case, an extra recording track was added to the data to allow a parity bit to be recorded with each byte. The parity bit is just the exclusive-or of all the data bits in the byte. If one track is read incorrectly, for example, because a bit of dust was caught between the tape and the record-playback head, the parity recorded on the tape will not match the parity computed from the data just read, so the error can be detected.

An example routine to output one block to tape is shown in Figure 10.

procedure writetapeblock( buffer: memoryaddress, length: int );
begin
     tape_address_register := buffer;
     tape_count_register := length;
     tape_control_register := write_command;
     while transfer_in_progress in tape_status_register do ;
end;

Figure 10. Write one block to tape, in Pascal.

Note that tape drives frequently support a number of commands which have no relation to the commands supported by other kinds of devices. Thus, in addition to the read and write commands, there may be special commands. The following list is typical of the special tape commands associated with classic 7-track and 9-track tapes. Most modern tape drives have significantly simpler interfaces without much of this complexity, but the use of dedicated microprocessors to control each drive makes it easy to include arbitrary complexity:

In addition to the complexity of the underlying command set, magnetic tape interfaces pose new problems because, after each read, the buffer is not the only item that must be returned! It is also necessary to return whether the block just read was an end-of-file mark and whether there were any errors. It would be possible to rely on exception handling mechanisms for this, but Unix and most other operating systems signal these conditions by special return values from the basic services. Thus, for example, in addition to reading a block of data into the indicated buffer, the Unix read-block services also returns the number of bytes read, with the convention that negative values indicates exceptions, with a secondary global variable, errno that gives the details of the exception.

For now, we will ignore this complexity and look at how we can integrate the tape interface into the file model from Figure 3. The first problem we face is that the primitive operations of our tape interface do not operate one character at a time, but rather, one block at a time, thus, the primitive operations we need are the read-block and write-block routines suggested in Figure 8.4, and not the read-character and write-character or read-line and write-line routines. From this, we arrive at the code outlined in Figure 11, presented under the assumption of an Intel x86-style interface to the I/O registers.

/* the definition of the type comportvariable based on filevariable */
struct tapevariable {
     /* pointers to device specific routines */
     void (* rewind)( struct filevariable * f );
     char (* get)( struct filevariable * f );
     void (* put)( struct filevariable * f, char c );
     void (* gets)( struct filevariable * f, char buf[], int len );
     void (* puts)( struct filevariable * f, char buf[], int len );
          .
          .   /* pointers to routines for other operations */
          .

     /* device specific information */
     int tapeaddr;   /* I/O register number of DMA address register */
     int tapecount;  /* I/O register number of DMA byte count register */
     int tapestatus; /* I/O register number of status register */
     int tapecommand;/* I/O register number of command register */
};

/* functions implementing access methods */
void putstape( struct filevariable * f, char buf[], int len )
{
     struct tapevariable * t = (struct tapevariable *) f;

     /* setup the address, assumed to be 32 bits */
     outp( t->tapeaddr,     ((int)buf      ) & 0xFF );
     outp( t->tapeaddr + 1, ((int)buf >>  8) & 0xFF );
     outp( t->tapeaddr + 2, ((int)buf >> 16) & 0xFF );
     outp( t->tapeaddr + 3, ((int)buf >> 24) & 0xFF );

     /* setup the 16 bit count */
     outp( t->tapecount,     ((int)len      ) & 0xFF );
     outp( t->tapecount + 1, ((int)len >>  8) & 0xFF );

     /* start operation */
     outp( t->tapecommand, TAPEWRITE );

     /* await operation complete */
     while ((inp(t->tapestatus) & TAPEDONE) == 0) /* do nothing */;
}

Figure 11. The core of a tape driver.

The code outlined in Figure 11 accounts for a troublesome characteristic of the IBM PC compatable device registers; like the device registers on many other systems, the minimal I/O bus allows only 8-bit data transfers. This is particularly annoying when the data being read or written is a 32 bit buffer address or a 16 bit block size, as suggested in the figure. This example does not rest on any real device; different real devices could have different limitations on the block size or address structure.

The code outlined in Figure 11 allows each block recorded on the tape to be of a different length! This allows a maximum of flexibility, but reading a tape made up of various block sizes could be quite difficult. IBM's OS 360/370/390 is one of the last systems to have been designed in the era when magnetic tape was one of the primary storage media used on computers. Under this system, when a tape drive is opened for input or output, the usual approach sets the block size once, and therefore, tape files always consist of a sequence of identical sized blocks. This requires a very complex parameter list for the open operation, both in programs and in the control language, but it eliminates many potential problems that can arise with the unconstrained scheme outlined here.

The Unix operating system, when magnetic tape was still common, provided two sets of device drivers for magnetic tape, a 'raw' driver which requires that the user handle the blocking or deblocking of data, and a 'cooked' driver which automatically constructs 1024 byte records using a variable length record packing scheme. Finally, Unix provides a utility called dd (an obvious pun on IBM's old job control language data definition statement) which can be used with the raw tape and disk interfaces to read or write 'foreign' tapes and convert them to standard Unix text files.

Blocking and Deblocking

Users of the magnetic tape interface suggested in Figure 11 are likely to want to read or write some tapes as streams of characters or as sequences of lines of text. To allow this, we must provide a new layer of software that allows characters to be gathered into blocks on output or blocks to be returned to the user one character at a time on input, and on top of this, we must put a layer that breaks lines into sequences of characters for output or constructs lines from sequences of characters.

There is a similarity between this problem and that illustrated by the getscom routine shown in Figure 8. This latter routine assumes, as primitives, a set of character sequential input-output routines and constructs, as a higher level routine, a line sequential routine. Our new goal is to assume, as low level primitives, block sequential operations, and use these to construct, at a higher level, a set of character sequential operations.

When we read or write blocks using character sequential primitives, we need no extra data structures, but if blocks are to be assembled from a sequence of character writes, or disassembled with the successive characters returned in a sequence of character reads, we need a place to store the block during assembly. Typically, what we will do is store the a buffer for this purpose as part of the open file descriptor. Figure 12 shows an appropriate extension of the basic data structures allowing this.

/* the definition of the type comportvariable based on filevariable */
struct tapevariable {
     /* pointers to device specific routines */
     void (* rewind)( struct filevariable * f );
     char (* put)( struct filevariable * f );
     void (* get)( struct filevariable * f, char c );
     void (* puts)( struct filevariable * f, char buf[], int len );
     void (* gets)( struct filevariable * f, char buf[], int len );
          .
          .   /* pointers to routines for other operations */
          .

     /* device specific information */
     int tapeaddr;   /* I/O register number of DMA address register */
     int tapecount;  /* I/O register number of DMA byte count register */
     int tapestatus; /* I/O register number of status register */
     int tapecommand;/* I/O register number of command register */

     /* blocking and deblocking information */
     char * tapebuffer; /* pointer to buffer used for deblocking */
     int  tapebufpos;   /* position in buffer */
     int  tapebufsize;  /* size of buffer */
};

/* functions implementing access methods */
void puttape( struct filevariable * f, char c )
{
     struct tapevariable * t = (struct tapevariable *) f;

     /* make sure a buffer is available */
     if (t->tapebuffer == NULL) {
          t->tapebuffer = malloc( t->tapebufsize );
          t->tapebufpos = 0;
     }

     /* put the character in the buffer */
     t->tapebuffer[t->tapebufpos] = c;
     t->tapebufpos = t->tapebufpos + 1;

     /* if the buffer is full, put it on tape */
     if (t->tapebufpos >= t->tapebufsize) {
          f->putstape( f, t->tapebuffer, t->tapebufsize );
          t->tapebufpos = 0;
     }
}
Figure 12. Blocking character sequential output to tape.

In Figure 12, we have assumed that, initially, tape files are opened without allocating space for a blocking or deblocking buffer. After opening, if a buffer is needed, it will be allocated by the character sequential input-output routines. The only piece of information provided by the open routine (the initializer) is the size of the buffer to be used for blocking or deblocking this tape.

The getstape service would move the data in the opposite direction and is left as an exercise for the reader.

An interesting puzzle comes up with the blocking and deblocking services. If all user interaction is at the block level, a user can read the blocks of a tape up to some point and then begin writing blocks, overwriting all of the remainder of the file from that point onward. We would like to construct the character sequential services to operate in the same way, so that a user can read the characters of a file up to some point and then start writing new characters. This requires that the read and write deblocking routines use the buffer and buffer position in compatable ways!

On the other hand, if the user tries to write the first few blocks of a magnetic tape and then read the remainder of the tape, we are not compelled to produce a meaningful result, since a sequence of write block operations followed by a read-block operation has no well defined meaning with variable-sized blocks on tape.

System Configuration

Although the specific data structures described in this chapter are not universal, they are quite typical, and all system designers face a similar problem: How are these data structures created initially? The process of installing the 'bare hardware' of a system includes the job of assigning specific device control register addresses, so it is not hard for the user to create a list giving, for each device, the addresses of each of its device control registers. The problem is to convert this to a collection of device description records in memory and an initial directory from which the "open" system service can construct file variables.

The process of creating a set of device description records and an initial directory is often called system generation. On the smallest systems, this is simply done by building these data structures in assembly language. On larger systems, neither of these alternatives is unreasonable -- modern PC's for example, are so complex that reconfiguring the BIOS for a new device is frequently nearly impossible, and most large systems are not distributed in source form so that the user can reconfigure the system by recompiling or reassembling.

One common alternative is to have a macro package which handles the details of creating the basic I/O data structures. Typically, there is one macro for each device type, where that macro takes, as parameters, the textual name to be associated with that device and the addresses of each of its device control registers. As a result, the source program which must be assembled to generate the system closely resembles the system description which the hardware people might have scribbled on a sheet of paper when they plugged all of the pieces together.

A second approach to system generation is to have a special sysgen program which reads a system configuration description which is written in a system configuration language. This program can be viewed as a very special compiler which builds an object file containing the initial data structures of the operating system. The system generation programs for some systems are interactive, prompting the user for each piece of information which may be needed, and explaining how to find information which is needed.

With IBM PC compatables, the BIOS, or Basic Input/Output Subsystem, provides the most basic input-output configuration information to the operating system as it is booted, and the problem of configuring the BIOS for a new device is typically handled by an interactive BIOS configuraiton program that prompts for the relevant details about the device. This works well only if the designers of the BIOS have anticipated the devices you intend to connect.

Independently of which approach is used, the system generation process for large systems also includes the selection of the set of device drivers which are to be linked into the system. On the PC, this is true because the drivers provided by the BIOS usually offer only poor performance and only support the I/O model of the old DOS system and not that of higher performance systems such as Windows or Linux. Most high performance systems are distributed with a very large set of device drivers, sometimes one for each device which has ever been widely sold for the machine in question. Clearly, there is no point to including fifteen different drivers for fifteen different makes of magnetic tape drive in the loaded operating system if only one kind of tape drive is actually attached to the machine! Typically, the selection of device drivers is done by selectively link editing the desired drivers into the object file holding the system, although nowdays, many drivers are dynamically loaded from disk only when they are first used.

On Unix and Linux systems, most of the system configuration is done dymamically. The data structures needed to mount the root file system are pre-built in the bootable system image loaded by the loader, but everything else is created by shell commands as the shell reads the boot script for the system. This boot script attaches the disk partitions other than the boot partition, and it mounts all other devices -- that is, creates the data structures needed by the input-output device drivers for those devices.

The Limits of Device Independence

The original goal of using file variables which could be attached to any device was to allow programs to be written which behave the same independently of which device they are actually attached to. This goal has been met by the device drivers described above only for those services which are supported in a common way by all devices. Thus, a program which produces output using only the "writeline" service can as easily produce output on tape as on the display screen, but a program which uses the "tapeskip" operation to read the blocks on a tape in a random order cannot be run using the keyboard.

This problem is a universal problem which all operating systems must face. In the oldest operating systems, device independence was not even attempted, and users were required to consider the characteristics of the devices being used when writing any input-output code. Starting in the mid 1960's, device independence became quite important. The Pascal language and early versions of the Unix system represent an extreme approach to device independence; few if any device dependent services were provided, so all programs could be run with any device. This extreme position can be viewed as an overreaction to the state of affairs which had existed previously. Clearly, there are times when device dependent features are desirable.

At the time of this writing, the pendulum may be swinging back from device independence to device dependence for a number of reasons: The emergence of windows and bitmapped displays has made it progressively less convenient to treat the display device in the same way as other devices. The specific properties of special hardware devices such as magnetic tape drives will always require special services. It is not convenient to treat communications lines between computer systems in the same way that communications lines to remote terminals are treated, even when the physical hardware interface is exactly the same. Finally, random access disk files support special operations which do not cleanly match those of any other class of device.

Despite the abandonment of absolute device independence, it is likely that most systems will preserve a kernel of device independent services comparable to that described here, with additional services provided for each class of device. Furthermore, most devices will fit cleanly into only one of a few large classes, for example, "display window", "tape", "disk" and "communications line". Although there will be some programs which are truly device independent, most will require that at least some of the files with which they deal exhibit the characteristics of one or more of these classes.

Terminology

The reader should be familiar with the following terms after reading this section:

sequential devices             full-duplex communications
device independence            line buffering
device identifier              terminals
device class                   canonical input forms
device drivers                 nine track magnetic tape
serial input-output ports      interblock gaps
memory mapped displays         recording density
device interface registers     transfer rate
polling loops                  end-of-file mark
device description records     tape labels
device control blocks          tape operations
bit mapped displays            tape errors
control characters             blocking factor
echoing                        system generation
half-duplex communications     even parity
odd parity
In addition, the reader should be familiar with the following system services of the example operating system:
read
write
readblock
writeblock
readline
writeline

Exercises

  1. The military has used, and may still use, some baud rates that are not listed in Figure 4. These include 45.5 and 75 baud. What baud rate divisors should you use for these rates.

  2. Using the data from Figure 4, and using the style of Figure 5, write code to initialize the COM1 port to 9600 baud, 8-bit data, 1 stop bit, odd parity.

     

  3. Using the "write" service shown in Figure 2, and the definitions given in Figures 8.3 and 8.4, write code for the following system services:

    a) "read" b) "readblock" c) "writeblock" d) "readline" e) "writeline"

  4. Using Figure 6 and Figure 8 as guides, write code for the following parts of the display driver from Figure 6:

    a) "comwriteline" b) "comwriteblock"

  5. Modify the code for "comreadline" in Figure 8 so that it handles a word-erase function. This function should be invoked when the user types an ASCII DEL (delete) control character ('\x7F' in C). A word is defined as follows in EBNF:
    <word> ::= ( <punc> | { <text> } ) { <space> }
    <space> ::=  -- the space character
    <punc> ::=   -- any printing character other than letters and digits
    <text> ::=   -- any letter or digit
    

  6. Write the "comreadblock" routine to match the other keyboard routines given in Figure 8 and preceeding figures, and then comment on why this routine would be very unlikely to be used for reading from the keyboard.

  7. Write the "tapereadblock" system service to match the "tapewriteblock" service defined in Figure 11. Assume that the device status register includes the following bits:

    Your routine should detect errors and take reasonable actions when they are detected. You must therefore extend the specification of the readblock routine, since no errors are mentioned in the specification that was given.

  8. Rewrite the "tapewriteblock" system service that was given, taking into account the fact that the tape must be moving at full speed in the forward direction before a read is legal. The tape control interface contains the following commands, in addition to the basic commands outlined prior to Figure 11.

    Assume the status register includes the following bits:

    Prior to trying to write a block, your code must first stop the tape if it is moving in the wrong direction and then restart it in the right direction, and in any case, it must not issue a write command until the tape is moving at full speed.

  9. Write the "taperead" routine to match the "tapewrite" routine given in Figure 12. Be careful to arrange things so that a change from reading to writing while moving the tape forward will go smoothly.

  10. Write a "tapewriteline" routine that uses the "tapewrite" routine given in Figure 12.

  11. The code in Figure 12 does not allocate a deblocking buffer initially, and only allocates one if it is readed. How can a user read the contents of a tape without ever allocating such a register?

References

Pages 254 through 259 of

Computer System Software, the Programmer/Machine Interface by R. S. Ellzey (SRA, 1987)
provide a short introduction to many of the issues discussed here. The description in Chapter 5 of
Software Systems Principles, A Survey by P. Freeman (SRA, 1975)
is much more detailed discussion of the general principles involved, without covering any device specific information.

A general discussion of device management is given in in Section10.4 of

Systems Programming by J. J. Donovan (McGraw Hill, 1972).
This includes a discussion and illustration of the storage of data on tape and disk.

The best source of information about the characteristics of particular devices are the manuals for the use of those devices. No particular manuals will be recommended here, but there is a large market in texts that describe the hardware device interfaces of the IBM PC. Most good bookstores will have several.

The C code shown in Figure 3 is based on the definition of device-independent input-output streams used in the OS6 operating system; see

OS6 - An Experimental Operating System for a Small Computer, Part 2: Input/Output and Filing Systems, by J. E. Stoy and C. Strachey, The Computer Journal 15, 2 (August 1972) 195-203.
Note that Strachey is the inventor of the CPL and BCPL languages, and that BCPL was an ancestor of the C language (through the short lived B language).

The UNIX device dependent service interface is described in Section IOCTL(2) of the

UNIX Programmer's Manual, Vol. I (Holt, Rinehart and Winston, 1983).
Sections MTIO(4) and TTY(4) in the same manual cover the magnetic tape and terminal dependent IOCTL calls. These are dismayingly complex and illustrate the oversimplification of the example services presented here.

Operating System Concepts by Peterson and Silberschatz (Addison-Wesley, 1983)
contains a reasonable discussion of system generation in Section 12.7.