Computing History · Unix Lineage

How argv and envp Reach a New Program Image

The argument vector as a byte layout the kernel copies onto the new stack, the size limits that constrain it, and why the environment is a separate array rather than more arguments.

A process receives its arguments as two arrays of pointers to strings, and those arrays do not live in any structure the program declared. The kernel builds them on the new stack immediately before transferring control to the entry point, and the shape of that construction is fixed by the ABI of the machine rather than by the language. A program can therefore read its arguments without a library call, and a debugger can display them without symbols, because the layout is a convention the loader and the C runtime both know.

The stack image at the entry point

execve takes a path, an array of argument pointers terminated by a null pointer, and a second array of environment pointers terminated the same way. The kernel reads both arrays from the caller’s address space, copies the strings into the new address space, and lays out the initial stack for the new image. It is the second half of the two-call process creation model described in the fork and exec article, and everything here concerns what the second call does with its three arguments.

The layout from the lowest address upward is: the strings themselves, then the pointer arrays, then a terminating null word, then the two count words. On x86-64 the stack pointer at the entry point points at argc, an eight-byte count. Immediately above it is argv[0], and the array runs for argc entries plus a null terminator. Above that is envp[0] and the environment array, again null-terminated. The string bytes sit at higher addresses, in an order the ABI does not fix.

        higher addresses
   +--------------------------+
   |  "PATH=/usr/bin:..."     |  string bytes
   |  "ls"                    |
   |  "-l"                    |
   +--------------------------+
   |  NULL                    |  envp terminator
   |  envp[n-1] ... envp[0]   |  pointers
   +--------------------------+
   |  NULL                    |  argv terminator
   |  argv[argc-1] ... argv[0]|
   +--------------------------+
   |  argc                    |  <- stack pointer at entry
        lower addresses

The System V AMD64 ABI states this arrangement explicitly because the dynamic loader and the C library startup code depend on it, and because a static binary with no libc still has to find its arguments. The convention predates the ABI document; it is how the original implementation passed arguments, and every later port kept it because changing it would break every program that reads argv from the stack.

The string order is deliberately unspecified. A program that compares the addresses of two argument strings, or assumes argv[0] is at a lower address than argv[1], is relying on something no specification guarantees. The pointers in the arrays are ordered; the bytes they point at are not.

Why the environment is a second array

execve passes two arrays, and the split is a design decision with visible consequences.

Arguments describe what this invocation of the program should do. The environment describes the context in which any program runs: the search path, the locale, the terminal type, the home directory, and a growing set of configuration conventions. Merging them into one array would force every program to know where its arguments end and the context begins, and would make the boundary part of each program’s calling convention rather than part of the interface. The split makes the boundary structural: argc counts arguments exactly, and the environment begins after a null pointer.

The environment is inherited rather than passed. execve accepts an explicit envp, but the common path is the exec family’s use of the global environ variable, which the C library maintains and which the process inherited from its parent. A shell that runs a command with a modified environment constructs the array itself, which is what the VAR=value command syntax does: it builds a new array, calls the exec function with it, and the modification is invisible to the shell because the array exists only for the duration of the call.

The environment has no schema. Each entry is a string of the form NAME=value, and the only structural rule is that the name contains no =. Anything beyond that is convention. PATH is a colon-separated list, IFS is a whitespace-separated list, TERM is a terminal type name, and nothing in the interface enforces any of it. This is why the environment accumulated: it is a namespace with no gatekeeper, so any library can add a variable, and the resulting set is a de facto registry with no owner.

#include <stdio.h>

extern char **environ;

int main(int argc, char **argv) {
    for (int i = 0; i < argc; i++)
        printf("argv[%d] = %s\n", i, argv[i]);

    /* environ is the array the C runtime captured at startup */
    for (char **e = environ; *e != NULL; e++) {
        char *eq = *e;
        while (*eq && *eq != '=') eq++;
        printf("env name length %zu\n", (size_t)(eq - *e));
    }
    return 0;
}

argv[0] is conventionally the program name as invoked, and it is the one argument the kernel does not control: the caller supplies it, so a program can be made to believe it was called by a different name. execve with a null argv or an empty array is permitted by POSIX, in which case argc is zero and argv[0] is a null pointer; a program that dereferences argv[0] unconditionally crashes on that input.

The limits and where they come from

The total size of the argument and environment strings is bounded by ARG_MAX, which POSIX requires to be at least 4096 bytes and which Linux reports through sysconf(_SC_ARG_MAX). Linux computes it from the stack size limit: a quarter of RLIMIT_STACK, capped at a maximum that has changed across kernel versions, with a floor of 32 pages. The exact figure is therefore a function of the resource limit and the architecture, not a constant, and a program that needs the number should ask rather than hardcode.

The limit applies to the strings, not to the pointer arrays, and it applies to the sum across both arrays. An argument list that fits in ARG_MAX but whose pointers push the initial stack past the stack limit fails with E2BIG. The kernel also limits the number of arguments and environment entries indirectly through the space they consume, and a single argument string longer than MAX_ARG_STRLEN (32 pages on Linux) fails even if the total would fit. That per-string limit is the one that catches generated command lines: a shell expanding a glob into one enormous argument hits it before it hits the total.

The practical consequence is that argument lists are a poor channel for bulk data. A program that needs to pass a large configuration blob should pass a file descriptor or a path, not a megabyte of arguments. The limit exists because the initial stack must be allocated before the program runs, and the kernel cannot grow it after the fact without violating the layout the ABI promises.

What the layout makes possible, and what it forbids

Because the pointers and strings are in the new address space and not in the old, the caller’s memory can be discarded entirely. This is what makes exec a replacement rather than a call: the new image starts with a stack that contains only its arguments, its environment, and the auxiliary vector, and nothing of the caller’s heap or data survives.

The auxiliary vector is the third array, above envp, and it is where the kernel passes information that is neither argument nor environment: the address of the program headers, the page size, the real and effective user and group IDs, the hardware capability bits, and a random seed for the stack canary. It is not part of POSIX; it is a convention shared by the ELF loaders, and the C library reads it to initialise itself before main runs. A statically linked program that bypasses libc must parse it directly, and the number and meaning of its entries is architecture- and kernel-dependent.

The layout also constrains what a program can do with its arguments afterwards. argv and environ are writable in the sense that the pointers are in the process’s own stack, but modifying them does not change anything outside the process, and the strings may share pages with each other. The setenv and putenv functions exist because the array the process started with is a fixed-size region: setenv may reallocate the pointer array, and code holding a saved pointer to the old array sees stale data. A library that caches environ at startup and expects it to stay valid is broken by any later setenv.

Finally, the argument vector is not a general serialisation format. It carries strings only, with no length prefix and no escaping, so an argument containing a null byte cannot be represented at all, and an argument containing a newline is indistinguishable from one that contains only text, because the newline is an ordinary byte inside the string. When the vector is written out for inspection, as /proc/<pid>/cmdline does, the arguments are separated by null bytes rather than by a printable delimiter, because no printable byte is safe; a reader that converts the nulls to spaces and then splits on whitespace loses the distinction between one argument containing a space and two arguments. That file is discussed in the procfs article.

References

  1. The Open Group Base Specifications Issue 7, System Interfaces: exec — POSIX.1-2017, IEEE Std 1003.1
  2. System V Application Binary Interface: AMD64 Architecture Processor Supplement — x86-64 psABI project
  3. Linux man-pages: execve(2) — Michael Kerrisk, man-pages project
  4. The UNIX Time-Sharing System — Ritchie & Thompson, Communications of the ACM, 1974