Chapter 57: Executing New Programs: The exec()
Family of Functions
Chapter Objectives
Upon completing this chapter, you will be able to:
- Understand the fundamental role of the
exec()
family in Linux process management. - Differentiate between the various functions in the
exec()
family (execl
,execv
,execle
,execve
,execlp
,execvp
) and select the appropriate one for a given task. - Implement C programs that use
exec()
functions to launch new applications on an embedded system. - Configure and pass command-line arguments and environment variables to new programs.
- Debug common errors related to path resolution, argument lists, and permissions when using
exec()
. - Integrate
fork()
andexec()
to create robust multi-process applications on a Raspberry Pi 5.
Introduction
In the world of embedded Linux, managing processes is a cornerstone of building robust and efficient systems. While the fork()
system call, which we explored previously, is adept at creating a new process by duplicating an existing one, it only accomplishes half the task of launching a new application. The newly created child process is initially just a clone, running the exact same code as its parent. To truly launch a different program, the system needs a mechanism to replace the current process’s memory image with that of a new program. This is the crucial role of the exec()
family of functions.
The exec()
functions are the engine of program execution in Linux. They are responsible for loading a new program into the current process’s address space, effectively transforming the process into a new one. This concept is fundamental to how shells operate, how system services are started, and how complex, multi-process applications are orchestrated. On an embedded device like the Raspberry Pi 5, where resources are managed carefully, understanding how to control program execution is paramount. Whether you are designing a custom user interface, a background service that launches helper scripts, or a complex control system, the exec()
family provides the necessary tools. This chapter will demystify these powerful functions, exploring their variations and providing the practical knowledge needed to wield them effectively in your embedded projects.
Technical Background
To fully appreciate the exec()
family, we must first revisit the concept of a process in Linux. A process is more than just executing code; it is a complete execution context managed by the kernel. This context includes the program code (text segment), global and static variables (data and BSS segments), the heap for dynamic memory allocation, the stack for function calls and local variables, and a set of processor registers, including the program counter (PC) and stack pointer (SP). When you run a command like ls
in your shell, the shell first calls fork()
to create a child process. This child is a near-exact copy of the shell. Then, the child process calls one of the exec()
functions to load the ls
executable into its memory space. The kernel overwrites the child’s existing memory segments with the new program’s segments, resets the registers, and starts execution from the new program’s entry point (_start
, which eventually calls main()
).
graph TD subgraph Parent Process A[Start: Shell Process PID: 500] end subgraph Child Process B[Cloned Shell Process PID: 501] -- "execve(/bin/ls, ...)" --> C{Transformed into New Program<br><i>/bin/ls</i><br>PID remains 501} end A -- "pid = fork()" --> B; A -- "fork() returns Child PID (501)" --> D["Parent Continues Execution<br>e.g., calls waitpid(501, ...)"]; C -- "Program completes" --> E[Child Process Terminates]; D -- "Waits for child" --> F[Parent Resumes after Child Terminates]; classDef startNode fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff classDef processNode fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff classDef systemNode fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff classDef endNode fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff class A startNode class B,D,F processNode class C systemNode class E endNode
The original process ID (PID) remains the same after an exec()
call. This is a critical point: exec()
does not create a new process; it transforms the existing one. The old program is completely gone, replaced by the new one. If the exec()
call is successful, it never returns to the calling program. The only way code after an exec()
call can be reached is if the call fails.

The exec()
Family: A Tour of Variations
The “exec” in exec()
stands for “execute.” However, there isn’t a single function named exec()
. Instead, the C library provides a family of related functions, each offering a slightly different way to specify the program to execute and its arguments. The naming convention of these functions provides clues to their behavior. The letters l
, v
, e
, and p
are suffixes added to the exec
base name.
l
(List): The command-line arguments are passed as a variable-length list of string pointers (const char *
) directly to the function. This list must be terminated by aNULL
pointer. This form is convenient when the number of arguments is known at compile time.v
(Vector): The command-line arguments are passed as aNULL
-terminated array of string pointers (char *const argv[]
). This is more flexible, as the argument array can be constructed dynamically at runtime.e
(Environment): This variant allows the caller to explicitly specify the environment variables for the new program. The environment is passed as aNULL
-terminated array of strings, where each string is in the format"NAME=value"
. If this suffix is absent, the new program inherits the environment of the calling process.p
(Path): This variant instructs the function to search for the executable file in the directories listed in thePATH
environment variable, just like a command shell would. If the filename contains a slash (/
), the path search is skipped, and it is treated as a relative or absolute path. If this suffix is absent, a full, absolute path to the executable must be provided.
These suffixes can be combined, leading to the six main functions in the family. Let’s explore each one in detail.
execl()
and execv()
: The Basic Duo
The simplest members of the family are execl()
and execv()
. They differ only in how they accept command-line arguments.
int execl(const char *path, const char *arg0, ... /*, (char *) NULL */);
The execl()
function requires the full path to the executable as its first argument. Subsequent arguments are the command-line arguments for the new program. By convention, the first argument (arg0
) should be the name of the executable itself. This is what the program sees as its name, which is important for programs that change their behavior based on how they are invoked. The list of arguments must be terminated by a NULL
pointer.
Imagine you want to execute the command /bin/ls -l /home/pi. Using execl(), the call would look like this:
execl("/bin/ls", "ls", "-l", "/home/pi", NULL);
This is straightforward and readable, but its static nature makes it unsuitable for situations where the number of arguments is determined at runtime.
int execv(const char *path, char *const argv[]);
The execv()
function also requires the full path to the executable. However, it takes the command-line arguments as a single array of strings (a vector). This array must also have the executable name as its first element (argv[0]
) and be terminated by a NULL
pointer.
To execute the same command, /bin/ls -l /home/pi
, you would first construct the argv
array:
char *const argv[] = {"ls", "-l", "/home/pi", NULL};
execv("/bin/ls", argv);
This approach is far more versatile. You can dynamically allocate and populate the argv
array based on user input, configuration files, or other runtime conditions, making it a staple in more complex applications.
graph TD subgraph "Goal: Execute /bin/ls -l /home/pi" Start("Start") end Start --> P1{"How are arguments known?"}; P1 -- "Fixed at compile time" --> L1["Use <b>execl()</b><br><i>(l for List)</i>"]; L1 --> L2["Arguments are separate C strings in the function call, followed by NULL."]; L2 --> L3["<pre><code>execl(\/bin/ls\, <br> \ls\, <br> \-l\, <br> \/home/pi\, <br> NULL);</code></pre>"]; P1 -- "Determined at runtime" --> V1["Use <b>execv()</b><br><i>(v for Vector)</i>"]; V1 --> V2["Build a <code>char*</code> array (vector) dynamically, terminated by NULL."]; V2 --> V3["<pre><code>char *args[] = {<br> \ls\, <br> \-l\, <br> \/home/pi\, <br> NULL<br>};<br>execv(\/bin/ls\, args);</code></pre>"] L3 --> End("Execution"); V3 --> End("Execution"); classDef startNode fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff classDef decisionNode fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff classDef processNode fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff classDef endNode fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff class Start startNode class P1 decisionNode class L1,L2,V1,V2 processNode class L3,V3 processNode class End endNode
execlp()
and execvp()
: Adding the PATH
Search
A significant inconvenience of execl()
and execv()
is the need to provide the full path to the executable. In a standard shell, you can simply type ls
without knowing it resides in /bin
. The execlp()
and execvp()
functions replicate this convenient behavior.
int execlp(const char *file, const char *arg0, ... /*, (char *) NULL */);
int execvp(const char *file, char *const argv[]);
These functions are identical to their non-p
counterparts, except for how they handle the first argument, file
. If file
does not contain a slash (/
), they search the directories specified in the PATH
environment variable to find the executable. If PATH
is not set, a default path (often "/bin:/usr/bin"
) is used. If file
contains a slash, the PATH
search is skipped, and it’s treated as a direct path.
So, to run ls -l /home/pi, you can now write:
execlp("ls", "ls", "-l", "/home/pi", NULL);
Or, using the vector form:
char *const argv[] = {"ls", "-l", "/home/pi", NULL};
execvp("ls", argv);
The kernel does the work of finding ls
in /bin
, /usr/bin
, or wherever it is located according to the PATH
. This is immensely useful for writing portable scripts and applications that don’t rely on hardcoded paths, a common requirement in embedded systems where the filesystem layout might vary.
execle()
and execve()
: Full Environmental Control
The final pair of functions, execle()
and execve()
, provide the ultimate level of control by allowing you to specify a custom environment for the new program. The environment of a process is a set of key-value pairs (e.g., PATH=/usr/bin:/bin
, HOME=/home/pi
) that can influence its behavior. By default, a new program inherits the environment of its parent. However, for security or configuration reasons, you might want to provide a minimal or modified environment.
int execle(const char *path, const char *arg0, ... /*, (char *) NULL, char *const envp[] */);
int execve(const char *path, char *const argv[], char *const envp[]);
These functions take an additional argument, envp
, which is a NULL
-terminated array of strings defining the new environment.

Suppose you want to run a custom script, /usr/local/bin/my_script
, and provide it with a specific SENSOR_ID
and a restricted PATH
.
Using execle()
:
char *const envp[] = {"SENSOR_ID=A42", "PATH=/bin:/usr/bin", NULL};
execle("/usr/local/bin/my_script", "my_script", NULL, envp);
Using execve()
, the most fundamental of all:
char *const argv[] = {"my_script", NULL};
char *const envp[] = {"SENSOR_ID=A42", "PATH=/bin:/usr/bin", NULL};
execve("/usr/local/bin/my_script", argv, envp);
In fact, execve()
is the only true system call in the family. All other exec()
functions are library wrappers that ultimately call execve()
after rearranging the arguments and possibly searching the PATH
. This makes execve()
the most powerful and flexible, as it gives the programmer direct control over all inputs to the new program. In security-sensitive embedded applications, sanitizing the environment by providing a custom envp
is a critical best practice. It prevents potentially malicious or misconfigured variables from the parent process from affecting the new program’s execution.
Practical Examples
Theory provides the foundation, but true understanding comes from hands-on practice. In this section, we will apply our knowledge to the Raspberry Pi 5. We will write, compile, and run C programs that demonstrate the use of the exec()
family.
For these examples, we assume you have a standard cross-compilation toolchain set up for the Raspberry Pi’5s architecture (AArch64). If you are developing directly on the Pi, you can use the native gcc
compiler instead.
Example 1: Basic Execution with execlp()
Our first goal is to create a simple C program that acts like a launcher. It will use fork()
to create a child process and then the child will use execlp()
to run the uname -a
command, which prints system information.
Code Snippet (launcher_uname.c
)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
// Fork failed
perror("fork failed");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// This is the child process
printf("Child process (PID: %d) is executing 'uname -a'...\n", getpid());
// Replace the child process with the 'uname' program
// 'p' in execlp means we search the PATH for 'uname'
// 'l' means we pass arguments as a list
execlp("uname", "uname", "-a", NULL);
// If execlp() returns, it must have failed
perror("execlp failed");
exit(EXIT_FAILURE); // Exit child with an error code
} else {
// This is the parent process
printf("Parent process (PID: %d) waiting for child (PID: %d)...\n", getpid(), pid);
int status;
waitpid(pid, &status, 0); // Wait for the child to terminate
if (WIFEXITED(status)) {
printf("Parent: Child exited with status %d\n", WEXITSTATUS(status));
} else {
printf("Parent: Child terminated abnormally\n");
}
}
printf("Parent process finished.\n");
return EXIT_SUCCESS;
}
Code Explanation:
- The program starts by calling
fork()
. - The
if (pid == 0)
block contains the child’s logic. It first prints its PID and then callsexeclp()
. execlp("uname", "uname", "-a", NULL)
tells the system to find theuname
executable in thePATH
, and run it with the argumentsuname
(asargv[0]
) and-a
. The list is terminated byNULL
.- Crucially, if
execlp()
is successful, the code that follows it (perror
,exit
) is never executed. Theuname
program takes over. - The parent process (
else
block) waits for the child to complete usingwaitpid()
and then reports its exit status.
Build and Run Steps
- Cross-Compile the Code:
aarch64-linux-gnu-gcc -o launcher_uname launcher_uname.c
(Orgcc -o launcher_uname launcher_uname.c
if compiling on the Pi). - Transfer to Raspberry Pi 5:Use scp to copy the compiled binary to your Pi.
scp launcher_uname pi@<raspberrypi_ip>:/home/pi/
- Execute on the Pi:Connect to your Pi via SSH and run the program.
ssh pi@<raspberrypi_ip> cd /home/pi ./launcher_uname
Expected Output
Parent process (PID: 2450) waiting for child (PID: 2451)...
Child process (PID: 2451) is executing 'uname -a'...
Linux raspberrypi 6.1.0-rpi7-rpi-v8 #1 SMP PREEMPT Debian 1:6.1.63-1+rpt1 (2023-11-24) aarch64 GNU/Linux
Parent: Child exited with status 0
Parent process finished.
This output clearly shows the parent creating the child, the child announcing its intent, and then the output of the uname -a
command appearing, followed by the parent confirming the child’s successful completion.
Example 2: Dynamic Arguments with execvp()
Now, let’s create a more flexible launcher that takes command-line arguments and executes them. This demonstrates the power of the v
variants. Our program will execute whatever command and arguments are passed to it.
Code Snippet (flexi_launcher.c
)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
fprintf(stderr, "Usage: %s <command> [args...]\n", argv[0]);
exit(EXIT_FAILURE);
}
// The arguments for execvp must start from the command name,
// not our launcher's name. We create a new argv array for this.
// argv[1] is the command, so it will be the new argv[0].
char **new_argv = &argv[1];
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// Child process
printf("Child executing: %s\n", new_argv[0]);
// 'v' for vector, 'p' for PATH search
execvp(new_argv[0], new_argv);
// This code only runs if execvp fails
perror("execvp failed");
fprintf(stderr, "Could not execute command: %s\n", new_argv[0]);
exit(127); // Standard exit code for "command not found"
} else {
// Parent process
int status;
waitpid(pid, &status, 0);
printf("Parent: Child process for '%s' finished.\n", new_argv[0]);
}
return EXIT_SUCCESS;
}
Code Explanation:
graph LR subgraph "Original argv from ./flexi_launcher ls -l" A0["argv[0]<br>./flexi_launcher"] --> A1["argv[1]<br>ls"]; A1 --> A2["argv[2]<br>-l"]; A2 --> A3["argv[3]<br>NULL"]; end subgraph "new_argv = &argv[1]" B0["new_argv[0]<br>ls"] --> B1["new_argv[1]<br>-l"]; B1 --> B2["new_argv[2]<br>NULL"]; end subgraph "Passed to execvp" C["execvp(<b>new_argv[0]</b>, <b>new_argv</b>)"] end P["<b>char **new_argv</b><br><i>Pointer to argv[1]</i>"] A1 -- " " --> P; P -- "Points to" --> B0; A1 -- "Becomes" --> B0 A2 -- "Becomes" --> B1 A3 -- "Becomes" --> B2 B0 -- "File to execute" --> C B0 -- "Argument Vector" --> C classDef original fill:#fef3c7,stroke:#f59e0b,color:#92400e classDef new fill:#d1fae5,stroke:#10b981,color:#065f46 classDef pointer fill:#e0e7ff,stroke:#4f46e5,color:#3730a3 classDef exec fill:#cffafe,stroke:#0e7490,color:#155e75 class A0,A1,A2,A3 original; class B0,B1,B2 new; class P pointer; class C exec;
- The program checks if it was given at least one argument (the command to run).
- The key line is
char **new_argv = &argv[1];
. This clever trick creates a new argument vector that starts from the second element of the originalargv
. For example, if you run./flexi_launcher ls -l
, the originalargv
is{"./flexi_launcher", "ls", "-l", NULL}
.new_argv
becomes{"ls", "-l", NULL}
, which is exactly whatexecvp()
needs. - The child process calls
execvp(new_argv[0], new_argv)
. It uses the first element of the new vector as the file to find and the entire new vector as the argument list.
Build and Run Steps
- Compile:
aarch64-linux-gnu-gcc -o flexi_launcher flexi_launcher.c
- Transfer:
scp flexi_launcher pi@<raspberrypi_ip>:/home/pi/
- Execute on the Pi:Try it with different commands.
./flexi_launcher ls -l
./flexi_launcher python3 -c "import os; print(f'Hello from Python, PID={os.getpid()}')"
./flexi_launcher not_a_real_command
Expected Output
For ./flexi_launcher ls -l
:
Child executing: ls
total 8
-rwxr-xr-x 1 pi pi 7560 Dec 15 10:20 flexi_launcher
-rwxr-xr-x 1 pi pi 7320 Dec 15 10:15 launcher_uname
Parent: Child process for 'ls' finished.
For the Python command:
Child executing: python3
Hello from Python, PID=2510
Parent: Child process for 'python3' finished.
For the non-existent command:
Child executing: not_a_real_command
execvp failed: No such file or directory
Could not execute command: not_a_real_command
Parent: Child process for 'not_a_real_command' finished.
This last example is important as it shows the error handling in action. execvp
fails, the perror
message is printed, and the child exits with an error code.
Example 3: Custom Environment with execle()
Our final example demonstrates how to provide a sanitized, custom environment to a new program. We will write a launcher that executes a simple Python script. The launcher will set a specific environment variable, CUSTOM_GREETING
, which the Python script will then read and print.
Python Script (show_env.py
)
#!/usr/bin/env python3
import os
import sys
# Get the custom environment variable
greeting = os.getenv("CUSTOM_GREETING", "Default Greeting")
path_var = os.getenv("PATH", "PATH not set")
print(f"Python Script (PID: {os.getpid()}) starting.")
print(f"Message: {greeting}")
print(f"My PATH is: '{path_var}'")
print(f"My arguments are: {sys.argv}")
Tip: Remember to make the Python script executable on the Pi:
chmod +x show_env.py
.
C Launcher (env_launcher.c
)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// Child process
printf("Child process preparing to execute script with custom environment.\n");
// Define the custom environment for the new program
char *const custom_envp[] = {
"CUSTOM_GREETING=Hello from the C launcher!",
"PATH=/bin:/usr/bin", // Provide a minimal PATH
NULL // The environment array must be NULL-terminated
};
// We must provide the full path to the script for execle
// The 'e' means we provide a custom environment
execle("./show_env.py", "MyPyScript", "arg1", "arg2", NULL, custom_envp);
// This code only runs if execle fails
perror("execle failed");
exit(EXIT_FAILURE);
} else {
// Parent process
wait(NULL); // Simple wait for child to finish
printf("Parent: Child finished.\n");
}
return EXIT_SUCCESS;
}
Code Explanation:
- The
custom_envp
array defines the entire environment for the new process. Note that we explicitly include aPATH
variable. If we didn’t, the Python script’sos.getenv("PATH")
would returnNone
. - The call to
execle
passes the path to the script, theargv
list ("MyPyScript"
,"arg1"
,"arg2"
,NULL
), and finally the custom environment array. - The Python script uses
os.getenv()
to retrieve the variables we set.
Build and Run
1. Compile the C code and transfer both files:
aarch64-linux-gnu-gcc -o env_launcher env_launcher.c
scp env_launcher show_env.py pi@<raspberrypi_ip>:/home/pi/
2. Prepare and run on the Pi:
ssh pi@<raspberrypi_ip>
cd /home/pi
chmod +x show_env.py
./env_launcher
Expected Output
Child process preparing to execute script with custom environment.
Python Script (PID: 2588) starting.
Message: Hello from the C launcher!
My PATH is: '/bin:/usr/bin'
My arguments are: ['MyPyScript', 'arg1', 'arg2']
Parent: Child finished.
The output confirms that the Python script received the custom greeting and the minimal PATH
variable we defined in the C code, not the parent’s shell environment. It also shows how custom arguments were passed and received.
Common Mistakes & Troubleshooting
The exec()
family is powerful, but with power comes the potential for error. Here are some common pitfalls encountered by developers, especially in an embedded context.
Exercises
- Simple Command Runner:Write a C program named run_ping that uses fork() and execl() to execute the command ping -c 4 8.8.8.8. The parent process should wait for the ping command to complete and print a “Ping test finished.” message. You must use execl() and provide the full path to the ping executable (hint: use which ping to find it).
- Environment Inspector:Write a C program named env_setter that launches the /usr/bin/env command. Use fork() and execle(). In your C code, create a custom environment for the env command that contains only three variables: USER=embedded_student, TERM=vt100, and DEVICE=rpi5. The parent should wait for the child and then exit. Verify that the output of your program only shows these three environment variables.
- Dynamic grep Tool:Create a program named find_text that acts as a simplified grep. It should be invoked like this: ./find_text <search_term> <file_path>. Your program should use fork() and execvp() to run the grep command with the provided search term and file path. For example, running ./find_text main /etc/passwd should execute grep main /etc/passwd. Implement error handling for incorrect argument counts.
- Daemon Launcher:Write a program that launches a “daemonized” process. The parent process should fork(), and then immediately exit, printing “Daemon launched.” The child process should continue running, print its new PID, and then use execlp() to replace itself with the top command. This simulates how a system service might launch a background task. (Note: The child becomes an orphan, and init or systemd will become its new parent).
- Building a Mini-Shell:This is a more advanced exercise. Create a program that reads a single line of input from the user, splits it into a command and arguments, and then executes it using fork() and execvp(). The shell should loop, allowing the user to enter multiple commands. For simplicity, you don’t need to handle pipes or redirection. The shell should exit if the user types “exit”. This exercise combines string manipulation, process creation, and execution.
Summary
- The
exec()
family of functions replaces the current process image with a new one. It does not create a new process; it transforms the existing one. - The standard model for running new programs in Linux is the
fork()
-then-exec()
pattern. - The different
exec()
variants provide flexibility in how arguments and the environment are specified:l
vs.v
: Pass arguments as a list or a vector (array).p
: Search thePATH
environment variable to find the executable.e
: Pass a custom environment variable array to the new program.
- A successful
exec()
call never returns. Code following anexec()
call is only executed if the call fails. - Proper error handling, including checking the
NULL
terminator on argument lists and handlingexec()
‘s potential failure, is critical for robust programs. - Controlling the environment and open file descriptors passed to a new program is a key security and stability consideration in embedded systems.
Further Reading
execve(2)
Linux Programmer’s Manual: The canonical source for theexecve
system call and its family. Accessible viaman 2 execve
on a Linux system.- Advanced Programming in the UNIX Environment by W. Richard Stevens and Stephen A. Rago: Chapter 8, “Process Control,” provides an authoritative and in-depth explanation of the
fork
,exec
,wait
, andexit
functions. - The Linux Programming Interface by Michael Kerrisk: Chapters 24 through 27 cover process creation and program execution in exhaustive detail.
- Buildroot User Manual: While not directly about
exec()
, understanding how an entire embedded Linux filesystem is constructed provides context for where executables reside and howPATH
is configured. (https://buildroot.org/downloads/manual/manual.html) - Yocto Project Mega-Manual: Similar to Buildroot, this provides essential context on the structure of embedded Linux systems. (https://docs.yoctoproject.org)
- Raspberry Pi Documentation – The Linux kernel: Official documentation on the Linux kernel used by Raspberry Pi, useful for understanding the underlying system. (https://www.raspberrypi.com/documentation/computers/linux_kernel.html)
- “How programs get run” – LWN.net: A detailed article explaining the low-level details of how the kernel loads and runs an executable. (https://lwn.net/Articles/631631/)