Loading content...
In the previous module, we explored fork()—the system call that creates a new process by duplicating the calling process. But here's a critical question: if fork() just makes a copy of the parent, how do we ever run a different program?
The answer lies in the exec() family of system calls. While fork() creates a new process, exec() transforms it. Together, they form the complete Unix process creation mechanism—a design so elegant and flexible that it has survived virtually unchanged for over 50 years.
But exec() isn't a single system call. It's a family of related functions, each with subtle differences that serve specific use cases. Understanding these variants—and knowing which to use when—separates confident systems programmers from those who guess and check.
By the end of this page, you will understand every major exec() variant, decode their naming conventions, recognize their semantic differences, and know exactly which variant to use for any given situation. You'll see why this apparent complexity actually provides elegant flexibility.
Before diving into variants, let's establish what exec() fundamentally does. The exec() system call performs a complete process image replacement. When a process calls exec():
What remains unchanged:
exec() does not create a new process—it transforms an existing one. The process retains its identity (PID) but completely changes its behavior. Think of it as a caterpillar becoming a butterfly: same organism, entirely different form.
1234567891011121314
#include <stdio.h>#include <unistd.h> int main() { printf("Before exec: PID = %d\n", getpid()); // This replaces the entire process image with /bin/ls // If exec succeeds, the following line NEVER executes execl("/bin/ls", "ls", "-l", NULL); // Only reached if exec fails perror("exec failed"); return 1;}Critical observation: If exec() succeeds, it never returns. The calling code is completely replaced by the new program. The only time exec() returns is when it fails—and in that case, it returns -1 and sets errno.
This "no return on success" semantic is unlike almost any other function in C. It's a one-way door: once you exec(), there's no coming back to your original code.
The exec() family follows a systematic naming convention that encodes the function's behavior in its name. Once you understand this convention, you can decode any exec variant instantly.
Every variant starts with exec followed by one or more suffix letters:
| Suffix | Meaning | Effect |
|---|---|---|
l | list | Arguments passed as a list (varargs) |
v | vector | Arguments passed as an array (char *argv[]) |
e | environment | Environment passed explicitly as parameter |
p | path | Uses PATH environment variable to find executable |
These suffixes combine to form the complete set of exec variants:
| Function | Arguments | Environment | Path Search | Signature |
|---|---|---|---|---|
execl | list | inherited | no | execl(path, arg0, arg1, ..., NULL) |
execv | vector | inherited | no | execv(path, argv[]) |
execle | list | explicit | no | execle(path, arg0, ..., NULL, envp[]) |
execve | vector | explicit | no | execve(path, argv[], envp[]) |
execlp | list | inherited | yes | execlp(file, arg0, arg1, ..., NULL) |
execvp | vector | inherited | yes | execvp(file, argv[]) |
execvpe | vector | explicit | yes | execvpe(file, argv[], envp[]) |
Among all these variants, only execve() is an actual system call. All others are library wrappers that ultimately call execve(). This is why execve() has both 'v' and 'e' in its name—it requires both explicit argument vector and explicit environment.
The most fundamental distinction in the exec() family is how arguments are passed. This comes down to compile-time knowledge: do you know the number of arguments when writing the code, or is it determined at runtime?
1234567891011121314151617181920212223242526272829303132333435363738
#include <unistd.h>#include <stdio.h> void using_list_variant() { // Known at compile time: exactly 3 arguments // execl(path, arg0, arg1, arg2, NULL) execl("/bin/ls", "ls", "-l", "-a", NULL); // arg0 is conventionally the program name} void using_vector_variant(int argc, char *argv[]) { // Runtime-determined arguments // Useful when forwarding arguments from another program // Build argument vector dynamically char *my_args[argc + 1]; // +1 for NULL terminator my_args[0] = "myprogram"; // arg0 = program name for (int i = 1; i < argc; i++) { my_args[i] = argv[i]; // Copy arguments } my_args[argc] = NULL; // NULL terminator required execv("/path/to/myprogram", my_args);} void shell_command_example() { // Real-world example: building a grep command dynamically char *pattern = "error"; // Could come from user input char *file = "/var/log/syslog"; // Could be dynamic // With list variant - awkward for dynamic arguments: execl("/bin/grep", "grep", pattern, file, NULL); // With vector variant - natural for dynamic arguments: char *grep_args[] = {"grep", pattern, file, NULL}; execv("/bin/grep", grep_args);}Both list and vector variants MUST have a NULL terminator. For list variants, the last argument must be (char *)NULL. For vector variants, the array must end with a NULL pointer. Omitting this causes undefined behavior—potentially reading garbage memory as arguments.
Practical guidance:
ls -l)main(argc, argv)The 'p' suffix variants (execlp, execvp, execvpe) add a crucial capability: PATH environment variable searching. This is how your shell finds commands without requiring full paths.
How path search works:
/), it's treated as a path (no search performed)ENOENT12345678910111213141516171819202122232425262728293031323334
#include <unistd.h>#include <stdio.h>#include <stdlib.h> int main() { // WITHOUT path search - must specify full path execl("/usr/bin/python3", "python3", "--version", NULL); // Error: if python3 isn't at exactly /usr/bin/python3, this fails // WITH path search - searches PATH automatically execlp("python3", "python3", "--version", NULL); // This checks each directory in PATH: // 1. /usr/local/bin/python3? No // 2. /usr/bin/python3? Yes! Execute it. perror("exec failed"); return 1;} // Demonstrating the search behavior explicitlyvoid show_path_search() { // Assume PATH=/usr/local/bin:/usr/bin:/bin // execlp("ls", ...) searches: // /usr/local/bin/ls - not found // /usr/bin/ls - not found // /bin/ls - FOUND! Execute. // execlp("/bin/ls", ...) does NOT search // because the filename contains '/' // It tries /bin/ls directly (same as execl)}The 'p' variants introduce security risks. An attacker who can modify PATH could redirect your exec to a malicious binary. For security-critical code (setuid programs, daemons, etc.), always use full paths with non-'p' variants. Never trust PATH in security contexts.
The conffile vs. PATH distinction:
Notice that 'p' variants take a file parameter while others take a path parameter. This isn't just terminology:
path = a pathname (absolute or relative), used as-isfile = a filename that may be searched for in PATHIf file contains no slashes, PATH is searched. If it contains a slash (like ./myprogram or /usr/bin/python), it's used directly as a path.
The 'e' suffix variants (execle, execve, execvpe) allow you to specify the environment for the new process explicitly. Without the 'e', the new program inherits the current process's environment unchanged.
Why control the environment?
Environment variables configure program behavior in foundational ways:
PATH – where to find executablesHOME – user's home directoryUSER – current usernameLANG/LC_* – localization settingsLD_LIBRARY_PATH – dynamic library search pathDATABASE_URL, API_KEY, etc.)Sometimes you need to:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
#include <unistd.h>#include <stdio.h>#include <stdlib.h> // The global environ variable holds current environmentextern char **environ; int main() { // Non-'e' variant: child inherits parent's environment execl("/usr/bin/env", "env", NULL); // Child sees all of parent's environment variables // 'e' variant: specify environment explicitly char *custom_env[] = { "PATH=/usr/bin:/bin", "HOME=/tmp", "MY_APP_MODE=production", "DATABASE_URL=postgres://localhost/mydb", NULL // NULL terminator required }; execle("/usr/bin/env", "env", NULL, custom_env); // Child sees ONLY the four variables we specified // Parent's other variables are NOT inherited perror("exec failed"); return 1;} // Security: creating a clean environmentvoid secure_exec() { // For setuid programs or security-sensitive operations, // never inherit the untrusted environment char *safe_env[] = { "PATH=/usr/bin:/bin", // Known-safe PATH "IFS= \t\n", // Safe IFS "HOME=/tmp", // Neutral home NULL }; // Execute with controlled environment only execve("/path/to/secure/program", argv, safe_env);} // Augmenting the environmentvoid add_to_environment() { // Sometimes you want parent's environment PLUS some additions // This requires constructing a new array int env_count = 0; for (char **e = environ; *e != NULL; e++) { env_count++; } // Create new array: original + 2 new + NULL terminator char *new_env[env_count + 3]; // Copy existing environment for (int i = 0; i < env_count; i++) { new_env[i] = environ[i]; } // Add new variables new_env[env_count] = "MY_NEW_VAR=value"; new_env[env_count + 1] = "ANOTHER_VAR=other"; new_env[env_count + 2] = NULL; execve("/path/to/program", argv, new_env);}The environment array (envp) follows the same format as argv: a NULL-terminated array of strings. Each string has the format 'NAME=value' (no spaces around the equals sign). The array must be NULL-terminated.
| Variant | Environment Behavior | Use Case |
|---|---|---|
execl, execv, execlp, execvp | Inherits parent's environ automatically | Normal program execution |
execle, execve, execvpe | Uses explicitly provided envp array | Security, sandboxing, configuration |
execve() is the only actual system call in the exec() family. All other variants are C library functions that eventually call execve(). Understanding execve() means understanding the fundamental interface to the kernel.
123456789
#include <unistd.h> // The actual system call signatureint execve(const char *pathname, // Full path to executable char *const argv[], // Argument vector (NULL-terminated) char *const envp[]); // Environment vector (NULL-terminated) // Returns: -1 on error (only return case), sets errno// On success: does not return (process image replaced)What happens inside the kernel when execve() is called:
Pathname resolution: The kernel resolves the pathname to an inode
Permission checks:
noexec?File format recognition:
#! (shebang)? → Invoke the interpreterMemory space preparation:
Security transitions:
Stack setup:
Jump to entry point:
_start symbol_start initializes runtime, then calls main(argc, argv, environ)Once execve() begins modifying the process memory, there's no undo. The old program is gone. If something fails during the loading process, the kernel must terminate the process rather than restore it—there's nothing left to restore. This is why exec() is atomic: it either succeeds completely or fails without any effect.
Let's examine each exec() variant with its complete signature, behavior, and typical use case.
1234567891011121314
int execve(const char *pathname, char *const argv[], char *const envp[]); // Example:char *args[] = {"ls", "-l", NULL};char *env[] = {"PATH=/usr/bin", "HOME=/tmp", NULL};execve("/bin/ls", args, env); // - pathname: Full path to executable// - argv: NULL-terminated argument array// - envp: NULL-terminated environment array // THE ONLY ACTUAL SYSTEM CALL// All other exec functions ultimately call thisexecve() is the fundamental system call that all others reduce to. If you need maximum control or are writing systems code, this is the variant to use. It requires both explicit argument vector and explicit environment.
The exec() functions have several subtle error conditions and common mistakes that can derail even experienced programmers.
| errno | Meaning | Common Cause |
|---|---|---|
ENOENT | No such file or directory | Wrong path, file doesn't exist, or command not found in PATH |
EACCES | Permission denied | File not executable, or directory in path not searchable |
ENOEXEC | Exec format error | File is not a valid executable (wrong architecture, corrupt binary) |
E2BIG | Argument list too long | argv + envp exceeds system limit (typically 2MB on Linux) |
ENOMEM | Out of memory | Insufficient memory to create new process image |
ETXTBSY | Text file busy | Executable is open for writing by another process |
EFAULT | Bad address | argv or envp points to invalid memory |
ELOOP | Too many symbolic links | Symlink chain is too deep or circular |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
#include <unistd.h>#include <stdio.h>#include <errno.h>#include <string.h> // MISTAKE 1: Forgetting the NULL terminatorvoid mistake_no_null() { // WRONG: No NULL terminator - undefined behavior! execl("/bin/ls", "ls", "-l"); // Missing NULL // CORRECT: execl("/bin/ls", "ls", "-l", NULL);} // MISTAKE 2: Using wrong pathname vs filenamevoid mistake_path_type() { // WRONG with non-'p' variant: execl("ls", "ls", "-l", NULL); // "ls" is not a path! // This looks for "./ls" or fails // CORRECT: Use 'p' variant for name-only execlp("ls", "ls", "-l", NULL); // Or specify full path for non-'p': execl("/bin/ls", "ls", "-l", NULL);} // MISTAKE 3: Ignoring exec failurevoid mistake_no_error_check() { // WRONG: No error handling execl("/bin/nonexistent", "nonexistent", NULL); // If we reach here, exec failed, but we don't know why printf("continuing normally...\n"); // Bad! // CORRECT: Always check for failure execl("/bin/nonexistent", "nonexistent", NULL); // If we reach here, exec definitely failed fprintf(stderr, "exec failed: %s\n", strerror(errno)); exit(1); // Or handle error appropriately} // MISTAKE 4: Expecting exec to return on successvoid mistake_expect_return() { execl("/bin/ls", "ls", NULL); // WRONG EXPECTATION: This code never runs on success! printf("ls finished\n"); // Never printed if exec succeeds} // MISTAKE 5: Wrong arg0 conventionvoid mistake_arg0() { // UNUSUAL (but valid): arg0 doesn't match program name execl("/bin/busybox", "ls", "-l", NULL); // This actually runs busybox, which uses arg0 to decide behavior // Works for busybox, but confusing for regular programs // CONVENTION: arg0 should be the program name execl("/bin/ls", "ls", "-l", NULL);} // MISTAKE 6: Modifying strings in argv (they should be const-ish)void mistake_modify_argv() { char *args[] = {"prog", "arg1", NULL}; // Some programs might try to modify argv for ps display // But exec copies these strings, so modifications after // exec setup are lost anyway}Any code that appears after an exec() call will only run if exec() failed. This means you should always have error handling after exec(). A common defensive pattern is to call _exit(127) after exec() fails, mimicking shell behavior for command-not-found (127 is the conventional exit code).
With seven variants to choose from, how do you pick the right one? Here's a decision framework:
execvp() — vector args + PATH search, covers most use casesexecve() — the system call itself, explicit everythingexecl() — clean syntax for fixed commandsexecvp() — dynamic args from parsed input + PATH searchexecve() — full path, explicit controlled environmentexeclp() — easy PATH search with fixed argsIf you're unsure, start with execvp(). It handles the most common case (runtime args + PATH search) and you can always switch to a more specialized variant if needed. Only use execve() when you specifically need environment control or are writing security-sensitive code.
We've thoroughly explored the exec() family of functions. Let's consolidate the key concepts:
What's next:
Now that you understand what exec() does and which variant to use, the next page dives deep into how exec() works internally. We'll explore the complete process of replacing a process image—from releasing old memory regions to loading new program segments to setting up the initial stack. Understanding this mechanism is essential for debugging exec() issues and appreciating the elegance of Unix process creation.
You now understand the complete exec() family: seven variants, their naming conventions, semantic differences, and appropriate use cases. You can decode any exec variant instantly and choose the right one for any situation. Next, we'll explore how process image replacement actually works at the system level.