Lab Exercise:  A Shell Program

Everything should be made as simple as possible, but not simpler --- Albert Einstein

Acknowledgement: This exercise is based on material prepared by Professor Gary J. Nutt of the University of Colorado.

Write a C program that will act as a command line interpreter (or shell) for the Linux kernel.  Your shell program should use the same style for running programs as the UNIX sh command.  In particular, when the user types in a line such as

      identifier [identifier [identifier]]
your shell should parse the command line to build the argv data structure in the form expected in any C main program.  It should then search the directory system in the order specified by the PATH environment variable for a file with the same name as the first identifier.  When the file is found, execute it with the optional parameter list specified by the other identifiers, just as sh does.

Background

A command line interpreter, or shell, program is a mechanism with which each interactive user can issue commands to the OS and by which the OS can respond directly to the user.  Whenever a user has successfully logged into the computer (a process will have been assigned tothe user when they begin the login procedure), the OS causes the user process to execute a shell.  The OS does not ordinarily have a built-in window interface -- instead the OS assumes a simple character-oriented interface in which the user types a string of characters (terminated with the Enter key), and in which the OS responds by typing lines of characters back to the screen.  The character-oriented shell assumes a screen display with a fixed number of lines (usually 25) and a fixed number of characters (usually 80) per line.

Shell Behavior

In an interactive system, a shell program is executed by the login process after it has authenticated the user.  Once the shell has initialized its data structures and is ready to start work, it clears the 25-line display, then prints a prompt in the first few character positions on the first line.  The Bourne shell (sh) prompt is usually $; the C shell (csh) prompt is usually %.  The shell then waits for the user to type a command line in response to the prompt.  The command line could be a string such as
    $ ls -al
terminated with newline character ('\n'.) When the user enter a command line, the shell's job is to cause the OS to execute the command embedded in the command line.

Every shell has its own language syntax and semantics.  In the bash shell, a command line has the form

    command argument_1 argument_2 . . .
where the command to be executed is the first word in the command line and the remaining words are arguments expected by that command.  The number of arguments depends on which command is being executed.  For example, the directory listing command can be used with no arguments --- simply by typing "ls", or it may have arguments prefaced by the "-" character, as in "ls -al" where "a" and "l" are arguments.  Other commands use their own syntax for accepting an argument; for example a C compiler command might look like
    $ gcc -g -0 deviation -S main.c inout.c -lmath
where the arguments "g", "o", "deviation", "S", "main.c", "inout.c", and "lmath" are all being passed as parameters to the GNU C compiler, "gcc".  That is, the command determines which of the arguments may be grouped (like the "a" and "l" in the ls command), which arguments must be preceded by a "-" symbol, whether or not the position of the argument is important, and so on.

The shell relies on an important convention to accomplish its task: The command for the command line are is usually the name of a file that contains an executable program.  For example, ls and gcc are the names of files (stored in /bin on most Unix-style machines.)  In a few cases, the command is not a file name, but is actually a command that is implemented within the shell; for example cd ("change directory") is usually implemented within the shell rather than in a file.  Since the vast majority of the commands are implemented in files, think of the command as actually being the file name in some directory on the machine.  This means that the shell's job is to find the file, to prepare the list of parameters for the command, then to cause the command to be executed using the parameters.

There is a long line of shell programs used with Unix variants, including the original Bourne shell (sh), the C shell (csh) with its additional features over sh, the Korn shell, and so on, to the standard Linux shell (bash --- meaning Bourne-Again shell.) All these shells have followed a similar set of rules for command line syntax, though each has its own special features.  The cmd.exe shell for WindowsNT uses its own similar, but distinct, command language.

Basic Unix-style Shell Design

A shell could use many different strategies to execute a user's computation.  The basic approach used in modern shells is to create a new process (or thread) to execute any new computation.  For example, if a user decides to compile a program, the process interacting with the user creates a new child process.  The first process then directs the new child process to execute the compiler program. This same technique can be used by the initial process (the OS) when it decides to service a new interactive user in a timesharing environment.  That is, when the user attempts to establish an interactive session, the OS treats this as a new computation.  It awakens a previously created process for the login port or creates a new procss to handle the interaction with the user.

This idea of creating a new process to execute a computation may seem like overkill, but it has a very important characteristic.  When the original process decides to execute a new computation, it protects itself from any fatal errors that might arise during that execution.  If it did not use a child process to execute the command, a chain of fatal errors could cause the initial process to fail, thus crashing the entire machine.

Unix shells accept a command line from the user, parse the command line, then invoke the OS to run the specified command with the specified arguments.  When a user passes a command line to the shell, it is interpreted as a request to execute a program in the specified file --- even if the file contains a program that the user wrote.  That is, a programmer can write an ordinary C program, compile it, then have the shell execute it just as if it was a normal Unix command.  For example, you could write a C program in a file named main.c, then compile and execute it with shell commands like
    $ gcc main.c
    $ a.out
The shell finds the gcc command (the GNU C compiler) in the /bin directory, then passes it the string "main.c" when it creates a child process to execute the gcc program.  The C compiler, by default, translates the C program that is stored in main.c, then writes the resulting executable program into a file named a.out in the current directory.  In the second command, the command line is just the name of the file to be executed, a.out (without any parameters).  The shell finds the a.out file in the current directory, then executes it.

Consider the detailed steps that a shell must take to accomplish its job:

This discussion provides the bare minimum functionality for a shell.  A production shell must also keep track of the current directory, implement several commands, handle background processes (invoked by terminating the command line with a "&" character rather than a NEWLINE), handling redirection of stdin and stdout, supporting the pipe operation ("|"), providing a scripting language, and so on.

Attacking the problem

There are several aspects of this lab exercise that might require more discussion.  The following sections describe:

Determing the command name and the parameter list

You should recognize the argv name in the execve call from writing C programs in your introductory programming classes.  That is, if you write a C program and you want the shell to pass parameters (from the command line) to your program, you declare the function header for your main function with a line like
int main(int argc, char *argv[])
The convention is that when your executable program (a.out) is executed, the shell will build an array of strings, argv, with argc entries in it.  argv[0] points to a string with the command name ("a.out"), argv[1] points to a string specifying the first parameter, argv[2] points to a string sepcifying the sceond parameter, and so on.  When your program is executed, it reads the argv array to get the strings, then applies whatever semantics it wants to interpret the strings.  For example you program might be run with a command line of the form
a.out foo 100
so that when your program begins execution it will have argc set to 3, argv[0] wiill point to the string "a.out", argv[1] will point to "foo", and argv[2] will point to the string "100".  You program can then interpet the first parameter (argv[1]) as, say, a file name, and the second parameter (argv[2]) as, say, an integer record count.  The shell would simply treat the first word on the command line as a file name and the remaining words as strings.

Refer to the Code Skeleton section for the following discussion.  After you have read the command line into a string, commandLine, you will need to parse it to populate the command_t fields (name, argc, and argv).  The explanation in the Background section should be sufficient for you to design and implement code to parse the command line so that you have the name of the file containing the command in command->name, and (a pointer to) the array of pointers to parameter strings in command->argv.

Finding the full pathname

The user may have provided a full pathname as the command name word, or only have provided a relative pathname that is to be bound according to the value of the PATH environment variable.  If the name begins with a "/", then it is an absolute pathname that cam be used to launch the execution.  Otherwise, you will have to search each directory in the list specified by the PATH environment variable to find the relative pathname.

Launching the command

The final step in executing the command is to fork a child process to execute the specified command to to cause the child to execute it. The following code skeleton will accomplish that:
    if (fork() == 0)
    {
        // this is the child
        // Execute in the same environment as parent
        execvp(full_pathname, command->argv, 0);
    }
    else
    {
        // This is the parent -- wait for child to terminate
        wait(status);
    }

Code Skeleton

The Background section provides a general, verbal description of how a shell behaves.  That description can be refined into the skeleton of a specific software solution with the following pseudocode:
struct command_t {
    char *name;
    int argc;
    char *argv[];
    . . .
};

int main()
{
    ...
    struct command_t *command;
    ...
    // shell initializationo
    ...
    // Main loop
    while(true)
    {
        // Print the prompt string
        ...
        // Read the command line and parse it
        ...
        // Find the full pathname for the file
        ...
        // Create a process to execute the command
        ...
        // Parent waits until child finishes executing command
    } // end while
    // Shell termination
    ...
} // end main

Implementation

An ultra-simple shell

The following example shell is from Kay and Steven Robbins of UTSA:

ush.h
ush1.c
makeargv.c