Every shell you have ever typed a command into does the same two-step trick: it forks itself, then replaces the copy with the program you asked for. That second step is the job of the exec family. This lecture, part of EmbeddedPathashala’s free Linux device drivers course track on process management, covers exec system calls Linux developers use every day, updated for the current glibc and kernel behavior rather than any decades-old reference.
If you’ve ever wondered how a shell runs ls, how redirection like > output.txt actually works under the hood, or how container runtimes launch the process a container ultimately runs, the answer runs directly through this lecture.
What You Will Learn
Prerequisites
This lecture builds directly on process termination in Linux (the previous lecture in this series) and assumes you already understand fork(). You’ll need a Linux machine with gcc and a shell — any modern kernel works, since the exec family’s behavior at the API level hasn’t changed in decades.
What exec() Actually Does
Unlike fork(), which duplicates a running process, exec() does the opposite: it discards the current process image entirely — code, data, heap, most of the stack — and loads a new program in its place, in the same process (same PID, same open file descriptors unless marked close-on-exec). If exec() succeeds, it never returns to the calling code; execution resumes at the main() of the newly loaded program. If it fails, it returns -1 and the original program keeps running, which is why every serious use of exec() checks its return value.
The Six exec() Variants
glibc provides six functions in this family, and the differences all come down to three independent choices: do you pass arguments as a list or an array, do you search PATH for the program, and do you supply a custom environment?
| Function | Arguments | Searches PATH? | Custom Environment? |
|---|---|---|---|
execl() | Variadic list | No | No (inherits caller’s) |
execlp() | Variadic list | Yes | No |
execle() | Variadic list | No | Yes |
execv() | Array (argv[]) | No | No |
execvp() | Array (argv[]) | Yes | No |
execvpe() | Array (argv[]) | Yes | Yes |
The naming is a mnemonic: l for list-style arguments, v for vector (array) arguments, p for “search PATH,” and e for “takes an explicit environment.” Once you know that, you can reconstruct the whole table from the function name alone.
A Minimal Command Launcher
Here’s an original demo, ep_minishell.c, that reads a command name, forks, and execs it — the same fork+exec pattern every real shell uses:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
char cmdline[128];
pid_t pid;
int status;
while (1) {
printf("ep-mini$ ");
if (!fgets(cmdline, sizeof(cmdline), stdin))
break;
cmdline[strcspn(cmdline, "\n")] = 0;
if (strlen(cmdline) == 0)
continue;
if (strcmp(cmdline, "quit") == 0)
break;
pid = fork();
if (pid == 0) {
/* child: replace this process image with the command */
execlp(cmdline, cmdline, (char *)NULL);
/* only reached if execlp() failed */
fprintf(stderr, "ep-mini: %s: command not found\n", cmdline);
exit(127);
} else if (pid > 0) {
waitpid(pid, &status, 0);
} else {
perror("ep-mini: fork failed");
}
}
return 0;
}
$ gcc -Wall -o ep_minishell ep_minishell.c
$ ./ep_minishell
ep-mini$ date
Tue Aug 25 09:14:02 IST 2026
ep-mini$ whoami
ravi
ep-mini$ quit
Notice execlp() was chosen specifically because it searches PATH — that’s why typing date works without a full path like /usr/bin/date.
Why Two Calls Instead of One
It’s a fair question: why does Linux split “run a new program” into a duplicate (fork) plus a replace (exec) instead of one combined syscall? The answer is that the gap between the two steps is exactly where a shell does useful work — redirecting file descriptors, setting up pipes, changing the working directory — all without the target program needing to know or care.
Here’s that redirection step in code — ep_redirect_demo.c runs ls with its output sent to a file instead of the terminal, without ls itself changing at all:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
pid_t pid = fork();
if (pid == 0) {
int fd = open("listing.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
perror("ep_redirect_demo: open");
_exit(1);
}
dup2(fd, STDOUT_FILENO); /* stdout now points at listing.txt */
close(fd);
execlp("ls", "ls", "-l", (char *)NULL);
perror("ep_redirect_demo: exec");
_exit(1);
}
waitpid(pid, NULL, 0);
printf("ep_redirect_demo: done, check listing.txt\n");
return 0;
}
Common Mistakes and Troubleshooting
| Mistake | Symptom | Fix |
|---|---|---|
Forgetting the NULL terminator in execl()/execlp() | Crash or garbage arguments passed to the new program | Always terminate the argument list with (char *)NULL |
Using execl() when you meant execlp() | “No such file or directory” for commands that clearly exist | Use the p variant when you want PATH search |
| Not checking exec’s return value | Code after exec runs unexpectedly on failure | Treat any return from a successful exec as impossible; handle the error path explicitly |
| Leaking unwanted file descriptors into the new program | New program can read/write files it shouldn’t have access to | Set FD_CLOEXEC on descriptors that shouldn’t survive exec |
Best Practices
- Prefer the
v(array) variants in code that builds argument lists programmatically — it avoids fragile variadic call sites. - Always fork before exec unless you genuinely want your current process replaced (rare outside of shells and launchers).
- Set
O_CLOEXEC/FD_CLOEXECon sensitive file descriptors so they don’t leak into child programs. - Check every exec call’s return value — a successful exec never returns, so any return means failure.
Performance consideration: exec is not free — it involves loading and linking a new executable image, so tight loops that spawn many short-lived processes should batch work instead of forking per item where possible.
Security consideration: never pass unsanitized user input to execlp()/execvp() as the command name, and prefer array-based exec calls over building a single shell command string, which avoids a whole class of shell-injection bugs.
Real-World Use Cases
Shells (bash, zsh), build systems invoking compilers, container runtimes launching the containerized entrypoint, and process supervisors like systemd all rely on exec system calls in Linux to hand control to another program while keeping the surrounding process machinery (PID, file descriptors, redirection) intact.
Summary and Key Takeaways
exec()replaces the current process image; it doesn’t create a new process.- Six variants exist, distinguished by argument style (
l/v),PATHsearch (p), and custom environment (e). - The fork-then-exec split is what lets a shell set up redirection and pipes before the target program runs.
- A successful exec never returns; code after it only runs on failure.
Conclusion
The exec family is deceptively small — six functions differing by three simple axes — but it’s the mechanism underneath nearly every program launch on a Linux system. Combined with what you learned about process termination in the previous lecture, you now have the two halves of the fork/exec/wait pattern that every shell, build tool, and process supervisor is built on. Next in this free Linux kernel development course, we’ll cover daemonizing a process properly.
Frequently Asked Questions
Does exec() create a new process?
No. It replaces the code and data of the calling process in place — same PID, same process table entry, just a different program running inside it.
What happens to open file descriptors across exec()?
They stay open by default, which is exactly what enables redirection tricks with dup2(). Descriptors marked FD_CLOEXEC are the exception — those are automatically closed on exec.
Which exec() variant should I use by default?
execvp() is the most commonly useful default: it takes an argv array (easy to build programmatically) and searches PATH like a shell would.
Why does my program keep running after a failed exec() call?
That’s expected — exec() only fails to return when it fails. Always check the return value and handle the error rather than assuming exec always replaces the process.
Can I exec() a shell script directly?
Yes, as long as it starts with a shebang line like #!/bin/bash — the kernel reads that line and re-execs the specified interpreter with your script as an argument.
Is fork() followed by exec() slower than a hypothetical combined call?
There is some overhead, largely mitigated by copy-on-write fork() implementations on Linux, but the flexibility of doing setup work between fork and exec is considered well worth the cost.
Continue the Free Linux Kernel Development Course
This lecture is part of EmbeddedPathashala’s free embedded Linux course covering process management from first principles.
Browse the Full Course Join the Community
2 Comments