Skip to content

Writing a basic shell

In this post, we’ll see how to write a basic shell that includes a read-eval loop and job control. The core read-eval loop was taken from Computer System’s: A Programmer’s Perspective (CS:APP), which is given as an example in the book. The job control part was written by me as a solution to exercise 8.26 of CS:APP.

The soruce files can be found here. The shell is written in C.

Shell basics

A shell is a program that executes other programs on behalf of the user. At the core of its functionalities, we have the fork and execve functions. With the first one, the shell creates child processes. It’s in their context that new programs are executed. execve takes care of the actual execution of programs. These child processes, and the group of processes that they create during their execution, are called jobs.

In general, a shell can execute a job in the foreground or in the background. At any time, there’s at most one job being executed in the foreground. The shell halts until the foreground process is terminated or interrupted. On the other hand, many background processes can run concurrently. After loading a background process, the shell continues its read and evaluation loop. Background jobs are executed by appending a & character at the end of the command line instruction (for example: > my_program &).

Once a job terminates its execution, it remains in the system memory until the shell, its parent process, “reaps” it. A process that is terminated but hasn’t been reaped is a zombie child. One important task of the shell is to reap all of its children. Not doing so creates memory leaks.

Basic shell functionalities

This basic shell has two main functionalities:

  1. Program execution: The shell executes programs that are in the current working directory (> my_program) or in the path specified by the user (> /bin/ls) in the context of child processes. Any strings, separated by spaces, that come after the program name are passed to it as arguments when it’s executed: > my_program arg1 arg2 ....
  2. Job control: The shell keeps track of all running jobs, which can be executed either in the foreground or in the background. The shell commands fg and bg can be used to move a job between the foreground and the background. Additionally, the foreground job can be terminated or stopped when the user types Ctrl+C and Ctrl+Z, respectively.

When the shell quits, it forces the termination of all of it’s running or stopped children and reaps them.

Main components

The shell is divided into 4 files: main.c, jobs.c, signals.c and wrappers.c.

  • main.c contains the read-eval loop. It parses the command-line input and evaluates it. It also sets the signal handlers and contains a block where signals flags are processed after being set by the signal handlers.
  • jobs.c contains all the functions that manage the jobs running in the background and foreground (if any). It stores the process-id (PID) and command-line arguments of every running job, updates the foreground job, reaps children and prints jobs statuses.
  • signals.c contains the signal handlers. There are handlers for SIGINT, SIGTSTP, and SIGUSR1. SIGINT and SIGTSTP are generated when the user types Ctrl+c or Ctrl+z. SIGUSR1 is send by the child processes to the parent when they are done setting their process-group ID (PGID). More details below. Additionally, this file contains a sigaction wrapper to set the signal handlers and a mask of blocked signals while handling signals.
  • wrappers.c contains wrappers for several functions that can generate errors. Many C functions return -1 to indicate an error (for example malloc). In order to avoid checking for errors on each function call, wrappers for every function were created. Each wrapper has the same name and prototype as the function it wraps, but it’s named starting with a captial letter. For example, Waitpid would be a wrapper that checks errors conditions for waitpid. These error-reporting function wrappers style was taken from Unix Network Programming as mentioned in CS:APP. A complete set of these wrappers is given in csapp.c, from where they were taken.

Structure of the shell

The main structure of the shell is in main.c. An overview is presented below.

Shell structure
Basic shell structure

Functions are enclosed in colored rectangles. Standeard C functions are in black, and main.c functions in blue. Portions of the code that call functions of different files are colored. Red for signals.c and green for jobs.c.

Some relevant portions of the program will be explained in the next sections.

Signal handling

The only function of all signal handlers is to set relevant global flags.

volatile sig_atomic_t terminate = 0;
volatile sig_atomic_t stop = 0;

The sig_atomic_t is used to ensure flags are set in a single instruction and can’t be interrupted. The volatile keyword is used to avoid the compiler caching the signals in CPU registers, which would make the changes in the flags during signal handling have no effect in the main program.

The SIGINT and SIGTSTP handler is shown below.

void sigint_sigtstp_handler(int sig) {
  Write(STDOUT_FILENO, "\n", 1);
  if (fg_job != 0) {
    if (sig == SIGINT)   
      terminate = 1;
    else if (sig == SIGTSTP)  
      stop = 1;
    siglongjmp(buf, 1);
  }
  return;
}

The handler first verifies there’s a foreground process in execution (fg_job global). Then, based on the signal that triggered the handler, it sets the appropriate flag. Finally, it jumps to the signal handling section of the code using siglongjmp. In case there’s no fg job in execution, the handler resumes normal execution of the shell.

Signal handling takes place before the read-eval loop in main.

if (sigsetjmp(buf, 1) > 0 ) {
  Sigprocmask(SIG_BLOCK, &blocked, &oldset);
  if (terminate == 1) 
    terminate_fg();
  else if (stop == 1) 
    stop_fg();
  terminate = stop = fg_job = 0;
  Sigprocmask(SIG_SETMASK, &oldset, NULL);
 }

It starts by blocking SIGINT and SIGTSTP signals to avoid modifying the flags while they are processed. Based on the flags, it either terminates or suspends the foreground process. Termination is done by issuing a SIGINT signal to the fg process group using kill. Stopping is done similarly but sending a SIGTSTP signal.

Once finished, all flags and the foreground job identifier are reset.

Storing job data

In order to keep track of all of the jobs, the shell stores the PID of all running jobs in a global array. Additionally, the PID of the foreground process is stored in fg_job.

#define MAXJOBS   16

pid_t jobs[MAXJOBS] = {0};
pid_t fg_job = 0;

Other data structures store additional information like the status of the job (running or stopped), and the command line string that was used to execuate each job.

static char job_status[MAXJOBS] = {0}; /* 0: Running, 1: Stopped */
static char *status[2] = {"Running", "Stopped"};
/* Stores only 16 chars of the command */
static char job_cmd[MAXJOBS][16];

Besides the PID, the shell assigns a small integer to each job, the job ID (JID). In this shell, we used the index of each job in the jobs array to get a JID. When a new process is executed by the shell, its PID is saved in the first “free slot” of jobs. A free slot has a value of 0. If the index in the array is i, the JID of the process will be i + 1.

When a process terminates and is reaped by the shell, its corresponding “slot” in jobs is reset to zero.

void release_job(pid_t pid) {
  jobs[get_jid(pid) - 1] = 0;
}

Most job management functions are very simple, like the one above, and just involve array traversal and setting some values.

In order for the job information to remain consistent, the SIGINT and SIGTSTP signals are blocked before updating these data structures. This avoids having the signals interrupt the processing of the job’s data.

Built-in commands

The shell recognizes the following built-in commands:

  • fg <proc>: executes <proc> in the foreground. If the process was stopped by a SIGTSTP signal, it resumes it by sending it a SIGCONT signal. <proc> identifies a process either by its PID or by its JID preceded by % (for example, %1).
  • bg <proc>: resumes <proc> execution in the background.
  • jobs: prints a list of all jobs that are either in execution or stopped.
  • quit: reaps all children (terminated or not) and exits the shell.

These commands are handled by builtin_command.

Child processes

All child processes (jobs) issue a SIGUSR1 signal to indicate the shell that they are done setting the process group ID. By default, the PGID of every child process is the PID of the parent. As a means of job control, all children set their PGID to their own PID. This way, the shell is able to control not only its direct children but all of the processes that belong to the children’s process groups.

However, if the shell were to send a signal to a PGID that hasn’t been set, this would raise an error. This is why the shell suspends its execution until it receives a signal from its child process.

This is the portion of the code of the child process:

Signal(SIGINT, SIG_DFL);
Signal(SIGTSTP, SIG_DFL);
/* Set pgid to children pid */
Setpgid(0, 0);
/* Send parent SIGUSR1 when done setting pgid */
kill(getppid(), SIGUSR1);
Sigprocmask(SIG_SETMASK, &oldset, NULL);
if (execve(argv[0], argv, environ) < 0) {
  printf("%s: Command not found.\n", argv[0]);
  exit(0);
}

The child starts by resetting the signal handlers to their defaults. Then it sets its PGID. When it’s done, it sends a SIGUSR1 signal to the parent. All but the last portion of the code is executed with the SIGTSTP and SIGINT signals blocked.

The shell suspends its execution while waiting for SIGUSR1 as follows.

sigset_t all_but_sigusr1;
Sigfillset(&all_but_sigusr1);
Sigdelset(&all_but_sigusr1, SIGUSR1);
sigsuspend(&all_but_sigusr1);

The first three lines create a mask that blocks all signals except SIGUSR1. The call to sigsuspend with this mask, will suspend the shell until it receives a SIGUSR1 signal (or it will resume immediately if the signal was pending). Before this portion of the code, SIGUSR1 signals were blocked (they are unblocked with the sigsuspend mask).

Example session

You can find the repo with all the source files here. The files can be compiled as:

$ git clone https://github.com/fabrizzio-gz/basic_shell.git
$ cd basic_shell
$ gcc -o shell -Wall main.c signals.c jobs.c wrappers.c

Then, we run it as:

$ ./shell
> ls
ls: Command not found.
> /bin/ls
jobs.c	 jobs.h   main.c   Makefile   README.md  signals.c   signals.h	wrappers.c   wrappers.h   write
jobs.c~  jobs.h~  main.c~  Makefile~  shell	 signals.c~  wait	wrappers.c~  wrappers.h~
> wait &
[1] 17715			wait &
> wait 
^Z
Job [2] 17716 stopped by signal: Stopped
> fg %2
^C
Job [2] 17716 terminated by signal: Interrupt
> write
1...
1...
1...
1...
^C
Job [2] 17718 terminated by signal: Interrupt
> wait &
[2] 17719			wait &
> jobs
[1] 17715 Running		wait  &
[2] 17719 Running		wait  &
> quit 

wait and write are dummy executables that run in infinite loops. The ^C and ^Z characters are the result of typing Ctrl+C or Ctrl+Z.

Published inProgramming
Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments