Skip to main content

How the Shell Executes Programs: Fork, Exec, and Environment Variables

· 12 min read
Pranav Ram Joshi
Software Engineer — Systems & Networks

Preamble

A shell process is a command-line interpreter — the program that reads your input, locates the right program or builtin command, and orchestrates its execution. Whether you use Bourne Shell, Bash, Korn Shell, or another flavor, the underlying mechanics are remarkably similar: the shell forks a child process, the child replaces its image with the target program via exec, and the parent waits for completion. In this post, we'll trace that fork-exec-wait cycle step by step, explore how environment variables are passed to programs, and look at the shell's capabilities as an interpreter — including loops, conditionals, and history expansion. This is the second in a three-part series. The first post covers UNIX terminal devices and line discipline, and the third explores writing portable C code.

What Is a Shell Process?

Common Shell Programs: sh, bash, ksh, and csh

A shell process is a command-line interpreter that reads commands from the user and coordinates their execution. There are various flavors of shell programs out there. Some of the ones are: Bourne Shell (sh), Bourne Again Shell (bash), C Shell (csh), Korn Shell (ksh), and many more. Normally, there are two ways to use the shell: enter command interactively, or write a shell script that will be processed by the shell process.

A shell, similar to a terminal device, is a complicated topic. The evolution of various shell programs brought many features we take for granted today: command history, tab completion, job control, and many other. Nowdays, we also have shell program that supports "plugins"; add external programs that allows the shell to perform as they wish. For instance, we can override the default prompt of the shell and add extra features such as colors, rich texts and the like.

How a Shell Executes a Program: The Fork-Exec-Wait Cycle

When you enter a line into the shell, it is assumed to be a command. If the last character of the line (before the newline character) was a backslash (\), the shell assumes the command is not complete and allows the user to continue the command in the next line. As assumed, the line is not considered complete unless there is no backslash at the end of the line.

There are two types of command: builtin shell commands, and the programs that are located in the directory specified by the PATH environment variable. An example of a builtin command would be the cd command. This command is used to change the directory to the provided argument to cd command.

caution

I will use the word command, program, and utility interchangeably. Although they might have their own distinct properties, I use it with a common understanding: they are a sequence of instructions crafted to achieve a task. I will try my best to use appropriate terminology wherever necessary.

Additionally, I won't dive deeper into environment variables. For now, we can think of it as a key-value pair that is used by many programs to work in an, well... environment.

tip

When you're working on a command-line interface, you will eventually encounter many commands available in your system. Understanding what every single one of them is almost impossible. This is where manuals turn out to be extremely helpful. To query a command, you can simply try out:

$ man <command>

and it will display a manual page for the <command>. Along with the man(1) utility, other utilities are also provided such as whatis(1), apropos(1), and which(1). The which(1) is unusually helpful when you have the same program (but with possibly different versions) in your system and you aren't sure which program is actually being executed. For example, if you have a program foo that is located in two directories; /bin and /usr/bin, the which(1) program could give the following output:

$ which foo
/bin/foo

If you want to use the other program, you would need to order your PATH environment variable (not recommended) or explicitly specify absolute path of the other program as:

$ /usr/bin/foo
...

Fork, Exec, and Wait: Step by Step

When you run a program within your shell program, the shell does the following:

  • The shell process forks itself. This means, a new process is created that has some characteristics of the process that instantiated it.[0] The newly created process is known as the child process whereas the other one is the parent process.
  • The child process is the one that will execute the program through the exec family of system calls, replacing the shell's process image with the new program. While the child is executing the command, the parent process waits for the child to terminate.[1] The child also inherits the terminal.
  • When the program is executed, the child process (which is that of the shell process) image is replaced by the image of the new process to be run.
  • While the parent is waiting for the child, the child (which is runnning the program) will use the terminal to interact with the user. The output of the program will be written to the terminal device (via the program's standard output stream and standard error stream, unless they are redirected) and the user can enter input in the terminal device that will be shown to the user as well as passed to the program under execution.
  • Once the program is finished (either successfully or not), the parent process is notified of this state change and the parent will take over the terminal, writing the prompt and waiting for the user input.

Builtin Commands vs External Programs

danger

This is where the distinction between a program and command should be considered. I use "run a program" above to be as clear about it. The above behavior is seen when executing a program, and is not necessarily the same behavior when the shell executes a command. For instance, observe the following shell process interaction below:

$ y=foo

$ echo $y
foo

You will see that assignment was persistent afterwards. In this case, we're doing a shell variable assignment. This is what builtin commands are for: to modify the state of the parent shell. This is also why cd must be a shell builtin command instead of an external program.

Setting Environment Variables for a Program

Passing Environment Variables on the Command Line

Imagine that you made a program that uses the environment variable. Web developers would be familiar with this concept as they have a dedicated .env file that stores this key-value pair (some even make it publicly available!) that will be used by the program eventually. A program can be instrumented to explicitly set the environment variable through call to library function setenv(3), but the shell also provides a feature to achieve this. We'll see a simple program as seen in Listing 3, where we'll fetch the value of environment variable foo, and display the value contained in that environment variable. For the sake of brevity, I won't use any build system to build the source file shown in Listing 3. The steps to compile the program and the output is shown in Listing 4.


#include <stdio.h>
#include <stdlib.h>

#define FOO_ENV "foo"

int
main (void)
{
char *foo_val;

if ((foo_val = getenv(FOO_ENV)) == NULL) {
fprintf(stderr, "%s not an environment variable.\n", FOO_ENV);
} else {
fprintf(stderr, "%s environment variable has the value: %s\n", FOO_ENV, foo_val);
}

return (0);
}
Listing 3: foo_env.c

Environment variable allows the programmer to instrument the program as per their needs. For example, we can create a function that will connect to a server specified by the environment variable SERVER_URL. This allows the same source file to work as needed with dynamic values such as URLs. Another use case is to allow debugging mode. If the user wanted to hide the debug logs to be shown, it could be wrapped inside an if statement and the program will initally check for the environment variable DEBUG to be present to enable debugging logs to be displayed.

We'll use gcc -Wall to compile; for a deeper look at the C compilation pipeline and portable build systems, see Part 3 of this series.


Script started on Sat Sep 13 21:24:38 2025

bash-5.3$ gcc -Wall -o foo foo_env.c
bash-5.3$ ./foo
foo not an environment variable.
bash-5.3$ foo=bar ./foo
foo environment variable has the value: bar
bash-5.3$ # the environment variable **bar** won't be used below
bash-5.3$ foo=bar bar=baz ./foo
foo environment variable has the value: bar
bash-5.3$ ^D
exit

Script done on Sat Sep 13 21:25:03 2025
Listing 4: Compiling and Executing foo_env.c

The environment variable list is provided as space-separated key-value pairs before the name of the program on the command line, allowing the user to provide multiple key-value pair for the program to work with. This list is appended to the current environment variables, overriding previous value if the variable name exists in the list.

The Shell as a Command-Line Interpreter

Loops and Conditional Statements in Shell

I've mentioned before that a shell is a command-line interpreter. This means that the shell process isn't limited to running programs, and is capable of being used as a programming language. Since the concept of shell was conceived over half a century ago and a lot has changed by now, the syntax understood by the shell process is bewildering. For example, Listing 5 shows a simple shell script that is not explicitly written as a shell script but written in the shell process itself. Of course, shell scripting isn't intended to be done like that, but we'll explore (one kind of) loop and conditional statement that is compatible for a shell process. It is a trivial program that loops for 6 times and checks if a number is even or odd. We can craft this script in another way to achieve the same result. For instance, we can use the test(1) utility to check whether the number is even or odd.

The following example demonstrates a simple for loop and if conditional statement written directly in an interactive shell session, checking whether each number is even or odd.


Script started on Sun Sep 14 12:39:34 2025

bash-5.3$ for i in {0..5}
> do
> if (( i % 2 == 0 ))
> then
> echo "$i is even"
> else
> echo "$i is odd"
> fi
> done
0 is even
1 is odd
2 is even
3 is odd
4 is even
5 is odd
bash-5.3$ ^D
exit

Script done on Sun Sep 14 12:42:09 2025
Listing 5: Conditional and Loop in Shell Process

Shell History Expansion with the ! Command

When we're interactively working with a shell process, it is sometime necessary to fetch previous command. The history builtin command is available to most shell programs today. It outputs the commands that the shell has processed. Of course, a shorthand for this is provided. The ! command is used as history expansion. Listing 6 shows some of the usage of the ! command. The !! command is used to run the immediate previous command that was entered. The !:<num> extracts the <num>'th argument from the previous command that was entered. And lastly, !<num> provides running the specific command. The <num> can be fetched from history builtin command. If <num> is negative, the command that is executed is relative to the current position in history list. For example, !-1 is equivalent to !!.

The following session demonstrates shell history expansion using the ! command in Bash, including !! to repeat the last command, !:<num> to extract arguments, and !<num> to re-run a specific command from the history list.


Script started on Sun Sep 14 13:13:57 2025

bash-5.3$ echo "first prompt"
first prompt
bash-5.3$ echo "second prompt\n"
second prompt\n
bash-5.3$ !!
echo "second prompt\n"
second prompt\n
bash-5.3$ printf !:1
printf "second prompt\n"
second prompt
bash-5.3$ history
...
208 echo "first prompt"
209 echo "second prompt\n"
210 echo "second prompt\n"
211 printf "second prompt\n"
212 history
bash-5.3$ !-2
printf "second prompt\n"
second prompt
bash-5.3$ !209
echo "second prompt\n"
second prompt\n
bash-5.3$ ^D
exit

Script done on Sun Sep 14 13:15:12 2025
Listing 6: History expansion in shell process
caution

Each shell program provides it own set of builtin(1) capabilities. Refer to the manual for more information. To dig further into this topic requires learning shell scripting, which is an entirely different topic.


This is Part 2 of a three-part series on terminals, shells, and portability:

  1. How UNIX Terminal Devices Work: TTY, Pseudo-Terminals, and Line Discipline
  2. How the Shell Executes Programs (you are here)
  3. Writing Portable C Code: Preprocessor Directives, Data Types, and GNU Autotools