Interactions between Processes and Device Drivers
Consider a C program running on a unix based computer. The program
has been running for a while, so we don't have to think about how things
get set up. It is reading data in text form from a file. It has already
read a few lines, and is about to read some more.
After a little
computation, the program calls the function getc() to read the next
character from the file. getc() is part of the standard C library
"stdio", not part of the operating system. getc() was written by people who
know how the system works, and who want to make things run as smoothly and
efficiently as possible. They realised that if getc() actually reads the
next character from the disc every time it is called, programs will run
very slowly (if it takes 10mS on average to make a disc access, a file
like this with a few thousand characters would take over a minute to
read).
To avoid terrible
inefficiency, getc() and all the other input and output functions in
stdio, use a buffer: a largeish array of characters kept aside for later
use. The first time a program calls getc(), it has no choice but to read
data from the disc. Instead of just reading the one character requested,
it will read a whole lot at the same time, at least a block (512
characters) and maybe more. All these characters are kept in the buffer,
so that next time getc() is called it can just take the next one
immediately without taking the time to perform a disc read operation.
This way, we only have to do one disc operation for every 512 (or more)
characters read, so the program should run approximately 512 times faster.
If you looked inside
stdio.c, the C library file where all the stdio functions are defined,
you would see something along these lines:
struct file_data
{ char buffer[512];
int next_char_pos;
int chars_in_buffer;
int file_reference;
.... };
typedef struct file_data FILE;
FILE *fopen(char *name, char *mode)
{ if (strcmp(mode,"r"))
{ FILE *newfile;
int file_ref=open(name,O_RDONLY);
if (file_ref<0)
{ maybe print an error message
return NULL; }
newfile=malloc(sizeof(FILE));
newfile->next_char_pos=0;
newfile->chars_in_buffer=0;
newfile->file_reference=file_ref;
return newfile; }
else
...... }
char getc(FILE *f)
{ char answer;
if (f==NULL)
return EOF;
if (f->next_char_pos >= f->chars_in_buffer)
{ refill_buffer(f);
if (f->chars_in_buffer <= 0)
return EOF;
f->next_char_pos=0; }
answer=f->buffer[f->next_char_pos];
f->next_char_pos+=1;
return answer; }
void refill_buffer(FILE *f)
{ int num_read;
num_read=read(f->file_reference, f->buffer, 512);
f->chars_in_buffer=num_read;
f->next_char_pos=0; }
char *fgets(char *string, int maxnum, FILE *f)
{ int ptr=0;
while (ptr<maxnum)
{ char c=getc(f);
if (c==EOF)
{ if (ptr==0)
return NULL;
else
break; }
string[ptr]=c;
ptr+=1;
if (c=='\n') break; }
string[ptr]='\0';
return string; }
Of course, things would be rather more complicated as they always are in
real life, but that is a pretty accurate picture. When you open a file
using the C library function fopen(), the fopen() function delegates all
the real work to the unix library function open(). open() is part of the
operating system, it knows how unix files work, and can handle them
properly; any program written in any language running under unix can call
on open() in principle. fopen() is part of the C library; all C compilers,
no matter what kind of computer they are running on, must provide a
fopen() function that works in exactly the same way. This allows C
programmers to write transportable programs. You can rely on fopen()
always being there and always working in exactly the same way, no matter
where your program is running. Each C compiler for each different kind of
computer and each different kind of operating system must provide a
different implementation of fopen() in order to guarantee this constancy.
fopen()s written for unix systems can rely on open() open being there, but
for other systems they will have to do something else. This is one of the
things you find out when writing your file-system project.
So, fopen() lets
open() do all the dirty work. open() returns as its result a small integer
that identifies the open file. All future file operations must provide
that integer to indicate which file is to be operated on. fopen() creates
a special data structure called a FILE to hold that vital number and
some other information. fopen() also sets aside the 512 bytes for the
buffer, and a couple of variables to keep track of how full the buffer is.
All this stuff goes in the FILE data structure. From now on, all C library
i/o functions pass around a pointer to that structure (a FILE * variable)
to gain access to the essential information.
When your program
calls getc(), it provides the FILE * as a parameter:
FILE *f=fopen("afile","r");
....
c=getc(f);
When getc() notices that its buffer is empty, it has to replenish the
buffer by performing a real read of data from disc. Under unix, there is a
function called read() (again part of the unix library, not the C library)
which actually does all the work (or at least sets things in motion).
read() is given three parameters: the file identifier, an indication of
how much to read, and a pointer to the place in memory to put it all. It
returns as its result the number of bytes it actually succeeded in
reading. This information is used to reset the buffer-fullness variables
appropriately.
If we pretend that
read() actually does all the work of reading data from a disc file itself
(and under a really primitive operating system like DOS, this could
be the case), that is all there is to it. We could chart the programs
progress through the different layers of the system. Normally when your
program is running, it is executing top-level code written by you. When
it calls getc(), it dips down into the next layer, the C run-time
library, which is hardly scratching the surface really: there is nothing
special about the stdio library, normal users are perfectly free to write
their own version of it. Occasionally, the C run-time library will have to
dip down into the next layer to do some real work, by calling open() or
read(). Here you are actually executing operating system code, but it is
still your normal, unpriviliged process that is doing it.
In graph form, it
would look like this:
Unfortunately, there
is more to it than that. In a real operating system, read() can't possibly
be allowed direct control of the discs. There could be hundreds of users
all wanting to use the disc at the same time, so there has to be some
central component to handle all the requests in a reasonable and
efficient way.
The read() function
simply passes the buck further down the line, but this time, a message is
sent to some other "process" to request that the work should be done.
read() constructs a little packet of data called an I/O Request Packet or
IRP for short (this is really just a special kind of data structure that
the operating system knows about). The IRP says how much data is to be
read form which file, and where it is to be put (essentially the three
parameters that were given to read()), together with the identification
number (or name) of the process that wants the data (i.e. the PID of your
process). When the IRP has been created, read() uses a very special
operating system function to add the IRP to the end of a special queue of
similar IRPs. All IRPs requesting disc activity are put on the end of the
same queue (so there is just one such queue in the whole system). The
"process" that directly controls the disc system takes these packets
from that queue and deals with them when it is ready.
So, read() uses
a special system function (often called QIO(), for Queue Input/Output
request) to add an IRP to the end of a queue. The special function QIO()
has to be used because there is only one queue for disc requests in the
whole system; it is a shared resource that every process adds data to at
radom moments. QIO() has to be very careful to use semaphores properly
to ensure that the queue soes not get destroyed by concurrent accesses.
Once the IRP has been
added to the queue, it could be some time before it is processed. There is
nothing your program can do in the mean time (if you called getc(), and
it returned before it had read the character, you would not be very
happy), so it goes to sleep. Once an IRP is added to the queue, the
process voluntarily goes to sleep. It does not return to the user program
yet, it goes to sleep while still executing the read() function.
When your IRP
is eventually processed, and the requested data is ready, your process
is woken up (that is why the PID has to be saved in the IRP. Otherwise
nobody would know who to wake when the data is ready). The read() function
continues executing as though nothing has happened, except that it can now
be confident that the requested data is ready, and it can return that data
to getc(), which can in turn put the data in its buffer and return one
character of it to the user program.
Extending the graph to
include this, we have:
The "process" that takes IRPs off the queue and deals with them is not
usually a true process. There is no real reason why it souldn't be one, it
just usually isn't. It is a Device Driver. Every real peripheral, such as
a disc system, a line printer, or an ethernet card, has a dedicated device
driver. Device drivers all do more or less the same thing. They spend
their entire lives looking after the device that they are responsible for.
Whenever they are not performing an essential task for the device, they
will take the first IRP off their queue and deal with it. In the case of
a disc device driver, that "dealing with" can usually only be the
beginning of an operation. When an IRP that requests a read-from-disc
operation is processed, the device driver will send the signals to the
disc drive telling it to prepare to read the requested data, but until
the disc rotates to the correct position and the heads settle over the
correct track, there is nothing more to be done. The device driver may
set the partially processed IRP aside, and deal with some others.
Eventually the device
driver will receive a signal from the disc saying that some data is ready.
It will then search through its list of set-aside IRPs until it finds the
one whose data is nor arriving. That IRP tells it where to put the data,
and which process to wake up. After the data is trasferred, the device
driver signals the relevant process to wake up, and destroys the IRP so
that the memory it occupies can be reused.
The input/output
operation is now complete, and the process that requested it continues
merrily computing. The IRP no longer exists, and the device driver
is continuing to deal with other IRPs as they appear in its queue.
A device driver is
a privileged almost-process always running in the background. It does all
the real work associated with its device; user and system processes ask it
to do things by adding little notes in the form of IRPs to its queue.
Some systems very confusingly refer to IRPs as "fork processes".