Developer Tools · Command-Line Craft

From Command Name to Process: The execve Argument Contract

How a typed command becomes a running program, what the kernel inspects before the first instruction executes, and which process state survives the replacement.

A command line such as git log --oneline -5 looks like a single act of naming. It is three separate acts: the shell decides which file on disk corresponds to git, it assembles two arrays of strings, and it asks the kernel to replace the running process image with that file’s contents. Only the last of those is a system call, and that call has no idea what a command name is. It receives a path, an argument vector, and an environment vector, and nothing else.

That division between naming and loading is the oldest interface in this cluster, and nearly every surprising behaviour around command lookup follows from it. The kernel cannot search PATH because PATH is a shell convention; the kernel cannot run a built-in because a built-in is not a file; and the kernel cannot tell an argument from a filename because both arrive as the same kind of string.

Path resolution is user-space policy

The primitive is execve. Its signature is short enough to memorise, and the shortness is the point:

#include <unistd.h>
#include <stdio.h>

int main(void) {
    char *const argv[] = { "greeter", "hello", NULL };
    char *const envp[] = { "PATH=/usr/bin:/bin", NULL };

    execve("/bin/echo", argv, envp);
    perror("execve");   /* only reached if the call failed */
    return 1;
}

The program above prints hello, not greeter, even though argv[0] says otherwise. Nothing in the kernel validates argv[0] against the file being executed. The convention that argv[0] names the program is maintained by whoever built the array, which is normally the shell, and a caller is free to place any string there. Utilities that print usage messages read argv[0] because it is convenient, not because it is trustworthy.

Because execve needs a path, the search across PATH has to happen in user space, and there are two independent implementations of it: the shell’s own, and the one inside the C library’s execvp family. Both follow the same POSIX rules: empty path prefixes mean the current directory, the search stops at the first file that can be executed, and failure to find anything is reported distinctly from finding a file that cannot be run. Two implementations of the same rule still diverge at the edges, which is why a command can be found by execvp and missed by a shell, or the reverse.

POSIX shells also cache lookup results. The cache is why installing a binary into a directory earlier in PATH may not take effect in an already-running shell: the name resolves from the remembered path, and the remembered path still points at the old file. The cache is an optimisation with a correctness cost that the user has to pay for manually.

Two arrays, one limit

argv and envp have the same representation: a NULL-terminated array of pointers to NUL-terminated strings. The environment is not a key-value structure at the kernel boundary, and it is not sorted. Duplicate names are permitted, and the C library’s getenv returns the first match it encounters. Programs that assume a single definition of a variable are relying on a convention that the interface does not enforce.

The combined size of both arrays and every string they point to is bounded by ARG_MAX. POSIX requires that limit to be at least 4096 bytes; Linux derives a much larger value from the stack resource limit, commonly on the order of two megabytes. Exceeding it produces E2BIG, and the failure arrives before any of the new program’s code runs, so the program has no opportunity to report a useful diagnostic. A command that works with a hundred filenames and fails with ten thousand is hitting this limit, not a bug in the utility.

The design consequence is worth stating plainly: the argument list is a fixed-size channel established before the program starts, and it cannot grow afterwards. Bulk data belongs on a stream or in a file. Arguments are also visible to other processes through /proc/<pid>/cmdline on Linux, which is a disclosure channel that no amount of application-level care closes.

What the kernel reads before any code runs

Two executable formats matter here. An ELF image is mapped and entered at its declared entry point. A script is recognised by its first two bytes, #!, and handled by a small parser inside the kernel: the rest of that first line is split into an interpreter path and, at most, one argument. Linux reads no more than 256 bytes of that line, so an interpreter path plus arguments that overrun the limit is silently truncated rather than rejected.

The kernel then constructs a fresh argument vector for the interpreter. The interpreter path becomes argv[0], the optional argument becomes argv[1], the script’s pathname is appended, and the original argv[1] onwards follows. That construction explains a great deal of otherwise mysterious behaviour. A script beginning #!/usr/bin/env python3 does not run Python directly: the kernel executes /usr/bin/env with python3 as its first argument and the script path as its second, and env performs a second PATH search before finally executing the interpreter. Two resolutions, two chances to select a different file than the author expected.

If the file is executable but matches no known format, execve fails with ENOEXEC. Both the shell and the C library respond by re-executing the file through /bin/sh. That fallback is why a file of shell commands with the execute bit set runs when named on a command line, and why the same file fails when a program calls execve directly. The behaviour lives in the caller, not in the kernel.

Errors that masquerade as missing commands

The errno values from execve are few and each maps to a distinct situation: ENOENT for a path that does not exist or a shebang interpreter that does not exist, EACCES for a file that exists but lacks the execute bit or for a directory, ENOEXEC for an unrecognised format, E2BIG for an oversized argument list, ETXTBSY for an executable that is still open for writing, and ELOOP for a symlink chain that exceeds the kernel’s resolution limit. The shell compresses these into two exit codes: 126 for a file that was found but could not be executed, 127 for a name that could not be found at all, as described in exit status conventions.

A related hazard is the gap between checking and executing. A program that calls access to confirm a path is executable and then calls execve on the same path has created a window in which the file can be replaced. The only race-free approach is to attempt the execution and interpret the error, or to open the file and execute through the descriptor with fexecve, which pins the inode the decision was made about.

State that crosses the boundary

Replacing the image does not reset the process. The pid, parent pid, process group, session, controlling terminal, working directory, umask, resource limits, credentials, and nice value all persist. Open file descriptors persist too, unless they were marked close-on-exec, which is precisely why O_CLOEXEC exists: a descriptor inherited by an unrelated child is a leak that can hold a pipe open, keep a socket alive, or expose a file the child had no business reading.

Signals behave asymmetrically, and the asymmetry is a frequent source of defects. Dispositions that were caught are reset to the default, because the handler’s code no longer exists in the new image. Dispositions that were ignored stay ignored. The signal mask is preserved unchanged. A program that sets SIGPIPE to be ignored before spawning children therefore passes the ignore along, and those children return EPIPE from writes instead of dying, which changes how a pipeline terminates, as described in pipe buffering and SIGPIPE. A thread that blocks signals before fork similarly hands a child a process that starts with those signals blocked.

The reverse direction is cleaner: memory mappings, the alternate signal stack, and all threads other than the calling one are destroyed. execve does not return on success, so error handling around it must be written as though it always fails.

Why a fork still precedes most execs

fork duplicates the address space, execve immediately discards it, and the work of copying page tables is proportional to the number of mapped pages rather than to resident memory. That waste motivated vfork, which suspends the parent until the child execs, and later posix_spawn, which may implement the same sequence with CLONE_VM and CLONE_VFORK to avoid the copy entirely. A shell that runs a single command as its last action can skip the fork altogether through the exec builtin, which is why exec some-server is the standard way to hand a process’s pid, signal dispositions, and terminal over to a replacement.

The limits of the interface are the limits of its arguments. There is no channel for structured data, no way to grow the vector, and no way to distinguish a filename from a flag once both are strings. Everything a program needs beyond a fixed list of strings has to arrive on a descriptor, which is why the next mechanism in this cluster is the pipe rather than the argument list.

References

  1. execve(2) — Linux manual page — Linux man-pages project
  2. POSIX.1-2017, Shell Command Language (Base Specifications, Chapter 2) — The Open Group
  3. The UNIX Time-Sharing System — Ritchie & Thompson, Communications of the ACM, 1974