All writing

What actually happens when you run a program?

Following a Linux program from a command typed into Bash, through command lookup and exec, into memory, dynamic linking, execution and finally process termination.

about 21 minutes min read

Typing a command and pressing Enter feels like one operation.

./hello

The program runs.

Simple.

Except that between my finger leaving the Enter key and the first line of hello appearing on the terminal, several different pieces of the system have had work to do.

The shell has to interpret what I typed. An executable has to be identified. Linux has to replace a process’s current program image with the new one. The executable has to be mapped into an address space. A dynamically linked program may need libraries located and loaded. Arguments and environment information need to be made available to the new program. Eventually the CPU begins executing its instructions.

And when the program finishes, there is another small chain of events before the shell gives me a prompt again.

I wanted a better mental model of that journey.

This is therefore my attempt to follow one ordinary Linux program from:

command line
     ↓
shell
     ↓
executable
     ↓
kernel
     ↓
process image
     ↓
program execution
     ↓
exit status
     ↓
shell

The precise details vary by shell, executable format, programming language and operating system. I am deliberately narrowing the question here to Bash on Linux running a normal compiled ELF executable.

That is still quite a rabbit hole.

First, a tiny program

I’ll use a small C program because it exposes the boundaries nicely without adding a language runtime such as Python or the JVM.

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    printf("Hello from process %s\n", argv[0]);
    return EXIT_SUCCESS;
}

Compile it:

cc hello.c -o hello

Then run it:

./hello

And perhaps get:

Hello from process ./hello

What happened?

The tempting explanation is:

Linux ran hello.

True, but not terribly illuminating.

The first participant was actually already running before hello existed as a process.

It was my shell.

1. Bash has to understand the command

When I type:

./hello

Bash does not immediately hand that string to the kernel.

The shell has its own language.

It reads input, divides that input into words and operators, parses those tokens into commands, performs shell expansions, processes redirections and then executes the resulting command.

That matters because something apparently simple such as:

./hello "$USER" *.txt > output.txt

contains considerably more work for the shell than the program eventually sees.

Bash may need to:

  • expand $USER;
  • expand *.txt into matching filenames;
  • remove the quote syntax after using it to control expansion;
  • arrange the > output redirection;
  • determine that ./hello is the command;
  • construct the argument list that the program will receive.

The program does not normally receive the literal text:

"$USER" *.txt > output.txt

The shell interprets that syntax first.

The GNU Bash manual’s description of shell operation is useful here, as is its more detailed section on simple command expansion.

So my first correction to the mental model is:

what I type
     │
     ▼
shell syntax
     │
     │ parsing + expansion + redirection
     ▼
command + arguments + execution environment

Already, “run this line” has turned into several operations.

2. Bash has to work out what the command means

There is another wrinkle.

Not everything I type at a Bash prompt is an external executable.

For example:

cd /tmp

doesn’t require Bash to find a /usr/bin/cd program and run it.

cd is a shell builtin. It needs to change the working directory of the shell itself, so executing it in an unrelated child process would not achieve what I want.

Bash therefore distinguishes between things such as:

  • shell functions;
  • shell builtins;
  • external executable files.

When a command name contains no slash and isn’t resolved as a function or builtin, Bash searches the directories listed in $PATH for an executable with that name.

So:

sleep 10

may involve a $PATH search.

Whereas:

./hello

contains a slash, so Bash does not need to search $PATH to discover which file I mean.

The exact Bash command-search sequence is documented in Command Search and Execution.

A useful experiment is:

type cd
type printf
type sleep

On my system this lets Bash tell me what kind of command each name resolves to.

This gives me another layer:

command name
     │
     ├── shell function?
     │
     ├── shell builtin?
     │
     └── external executable?
                │
                ▼
           locate program

For this article, ./hello lands firmly in the final branch.

It is an external executable.

3. The shell needs a process in which to run it

My February note established an important distinction:

A program is not the same thing as a process.

hello exists on disk as an executable file.

To execute it, there needs to be a process executing that program.

For an ordinary foreground external command, Bash invokes the command in a separate execution environment. That environment inherits things from the shell, including open files, the current working directory and exported environment variables, with adjustments for any redirections associated with the command.

The Bash documentation describes this in Command Execution Environment.

At the Unix interface, process creation and program execution are interestingly separate ideas.

fork() creates a child process.

exec then allows a process to replace the program it is currently executing with another program.

A simplified picture is:

Bash process
     │
     │ create execution context
     ▼
child process
     │
     │ exec ./hello
     ▼
child process running hello

The crucial point is that exec itself does not create another process.

Linux’s execve(2) manual page explicitly warns against describing it that way.

Instead, execve() replaces the program currently being executed by the calling process with a new program.

So conceptually:

before execve()

PID 4120
┌──────────────────┐
│ child process    │
│ executing shell- │
│ related code     │
└──────────────────┘


        execve("./hello", ...)


after successful execve()

PID 4120
┌──────────────────┐
│ same process     │
│ executing hello  │
└──────────────────┘

The process identity can remain while the program image changes.

That distinction turns out to be one of the most useful ideas in understanding Unix processes.

4. execve() crosses into the kernel

Eventually user-space code asks the kernel to execute the program.

On Linux, the underlying system call at the centre of the exec family is:

execve(path, argv, envp);

Its three arguments are revealing.

path identifies the program to execute.

argv contains the argument vector for the new program.

envp contains its environment.

The Linux execve(2) documentation describes the interface and the attributes that are preserved or replaced during execution.

A successful execve() is unusual from the caller’s perspective:

it does not return.

If it succeeds, the old program image has been replaced.

If it returns -1, something went wrong.

For example:

  • the executable might not exist;
  • execute permission might be missing;
  • the executable format might be invalid;
  • an interpreter named by the executable might not exist;
  • resource limits might prevent execution.

So the boundary looks roughly like:

user space
────────────────────────────────

Bash / child execution code

        │
        │ execve(...)
        ▼

════════ system-call boundary ════════

        ▼

Linux kernel
────────────────────────────────

Now the kernel has to work out what exactly it has been asked to execute.

5. Linux examines the ELF and builds the process image

My compiled hello is not just an arbitrary bag of machine instructions.

On a typical Linux system, it is an ELF file: Executable and Linkable Format.

I can verify that with:

file ./hello

and inspect its ELF structures using tools such as:

readelf -h ./hello
readelf -l ./hello

Linux’s elf(5) documentation describes the ELF format.

An ELF executable begins with an ELF header and can contain a program header table describing segments relevant when the program is loaded.

This is an important vocabulary distinction:

ELF file
├── ELF header
├── program headers
├── sections
└── program data/code

Not every byte in the file is simply copied wholesale into RAM.

The loader uses information in the executable to establish the new process image. For execve(), the old program’s text, data and stack are replaced by the newly loaded program image.

But even “loaded into memory” can suggest the wrong picture.

A process operates within a virtual address space. Linux can establish mappings between portions of that address space and executable files, libraries or anonymous memory.

The mmap(2) documentation describes memory mappings, while /proc/<pid>/maps lets me inspect the mapped regions of a running process.

For example, if I make the program stay alive for a while:

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

int main(void)
{
    printf("PID: %ld\n", (long)getpid());
    sleep(60);
    return EXIT_SUCCESS;
}

I can run it and, using its PID, inspect:

cat /proc/<pid>/maps

The output can contain mappings corresponding to:

  • the executable;
  • shared libraries;
  • anonymous writable memory;
  • the process stack;
  • kernel-provided mappings.

So rather than imagining the executable being copied wholesale into RAM, a better early mental model is:

                process virtual address space
                ┌─────────────────────────────┐
ELF executable ─► executable mappings         │
                │                             │
shared library ─► library mappings            │
                │                             │
                │ writable / anonymous memory │
                │                             │
                │ stack                       │
                └─────────────────────────────┘

Virtual memory deserves an article of its own, but this is enough to stop me thinking of execution as a simple file-copy operation.

6. Dynamic linking and runtime startup

My hello program is likely dynamically linked unless I deliberately built it otherwise.

I can check:

file ./hello

or inspect its program headers:

readelf -l ./hello

A dynamically linked ELF executable can contain a PT_INTERP program-header entry naming the interpreter that should participate in loading it.

On a glibc-based Linux system this is normally a variant of the GNU dynamic linker/loader.

Linux’s execve(2) documentation explains that for dynamically linked ELF binaries, the interpreter named by PT_INTERP is used.

The dynamic linker then has work of its own.

According to ld.so(8), the dynamic linker/loader finds the shared objects the program needs, loads them, performs runtime linking work and transfers control so execution can continue.

This is why a tiny C program can depend upon libraries that are not physically embedded in its executable file.

I can inspect its dependency tree with:

ldd ./hello

although I would not use ldd on an untrusted executable. The ldd(1) documentation warns about this and suggests alternatives such as:

objdump -p ./hello | grep NEEDED

for inspecting direct dependencies.

The new program also needs more than executable instructions.

execve() receives both:

argv  → argument vector
envp  → environment

For:

./hello one two

a C program can observe arguments through:

int main(int argc, char *argv[])

with entries conceptually resembling:

argc = 3

argv[0] = "./hello"
argv[1] = "one"
argv[2] = "two"
argv[3] = NULL

The GNU C Library documents this in Program Arguments.

The process also receives an environment. For example:

export COLOUR=blue
./hello

The executed program can inherit that exported variable from the shell.

The glibc documentation on Environment Variables describes this mechanism, and Linux’s environ(7) documents the process environment.

Linux also provides an auxiliary vector containing information useful to user-space startup code and the dynamic linker. The getauxval(3) manual page describes this as a mechanism used by the kernel’s ELF loader to pass information into user space.

So startup involves more than:

Here are some machine instructions. Good luck.

There is an agreed runtime interface between the operating system and the program being started.

And the CPU does not begin at my C main() function.

At the executable level, an ELF file contains an entry point. Runtime startup machinery executes before main() is invoked, prepares the C execution environment and eventually arranges for main() to receive its arguments.

The conceptual chain is closer to:

kernel loads ELF
       │
       ▼
dynamic linker/loader
       │
       ├── locate shared libraries
       ├── map required objects
       └── perform runtime linking work
       │
       ▼
ELF entry point
       │
       ▼
runtime startup
       │
       ▼
main(argc, argv)
       │
       ▼
my application code

That distinction matters because main() belongs to the language/runtime view of the program, while the operating system needs an executable-level entry point.

Different programming languages can put very different machinery between those two worlds. A Go binary, Python script and Java application may all eventually “run”, but their startup paths are not identical.

So even the statement “Linux loads my executable” hides cooperation between the kernel and user-space runtime machinery.

7. Once execution reaches my application code

By the time control reaches my application code, the process has its executable mappings, stack, program data, startup context and any required dynamically loaded libraries.

But even saying:

“the process runs”

is slightly misleading.

On a multitasking operating system, many runnable tasks may be competing for CPU time. The kernel scheduler decides which runnable task gets to execute on which CPU and when.

Our program therefore alternates between executing instructions in user space and, when it needs operating-system services, crossing into the kernel through system calls.

For example, eventually:

printf("Hello\n");

needs the outside world to become involved if those bytes are to appear on a terminal or other output destination.

The application cannot simply manipulate a terminal device however it likes. User programs request kernel-managed operations through the system-call interface.

This user-space/kernel-space boundary is fundamental enough that it deserves much more attention later.

For now, I just need to recognise that a running program is not executing in splendid isolation. It repeatedly interacts with operating-system facilities.

8. What happens to standard input and output?

My program also appears to somehow know where this goes:

printf("Hello\n");

That information did not come from the executable.

Processes conventionally begin with file descriptors for:

0  standard input
1  standard output
2  standard error

A child execution environment can inherit open file descriptors from the shell, subject to things such as close-on-exec behaviour and shell redirections.

This is why:

./hello

can print to my terminal, while:

./hello > hello.txt

can run the same executable and write its standard output somewhere completely different.

Bash arranges the redirection before program execution. The program can simply write to its standard-output file descriptor.

That gives me an important separation of concerns:

program:
    "write this to stdout"

shell:
    "stdout currently points here"

kernel:
    "manage the actual file/device"

The same program doesn’t need one version for terminal output and another for redirected output. The execution environment supplies that context.

The Bash manual covers redirection as part of its command-processing model and describes inherited open files in Command Execution Environment.

9. Finishing, waiting and reaping

My program contains:

return EXIT_SUCCESS;

Eventually it finishes.

For a normally terminating C program, returning from main() results in normal program termination and an exit status being made available.

For a normal foreground command, Bash waits for the command to complete and collects that status.

It then becomes available in Bash as:

$?

So:

./hello
printf 'status=%s\n' "$?"

might produce:

Hello from process ./hello
status=0

The line:

Hello from process ./hello

came from the program.

The:

0

is something different: the program’s termination status as observed by the shell.

That distinction will matter later when I look at shell automation.

A program can print something that looks disastrous and return zero. Or print nothing whatsoever and return failure.

Output and success are separate channels.

There is also a small afterlife.

When a child terminates, its parent may still need to collect its termination information. Linux retains enough information for the parent to obtain the child’s status. If that status has not yet been collected under the usual conditions, the terminated child can temporarily exist as a zombie.

That sounds much more dramatic than it is.

Most of the process’s resources are already gone. The kernel retains a small amount of information so the parent can learn how the child terminated.

The exit(3) documentation describes normal process termination and this retained status information. wait(2) covers how a parent waits for child state changes, obtains termination information and allows the remaining bookkeeping to be released.

So even:

program finished

is not quite the final line of the story.

There is still bookkeeping to complete between parent and kernel.

Putting the whole journey together

My original model was:

type command
     ↓
program runs

A better Linux/Bash model now looks like this:

I type:

    ./hello one two > output.txt

                 │
                 ▼
        ┌─────────────────┐
        │      Bash       │
        │                 │
        │ read            │
        │ tokenise        │
        │ parse           │
        │ expand          │
        │ redirect stdout │
        └────────┬────────┘
                 │
                 │ identify external executable
                 ▼
        execution environment
                 │
                 │
                 ▼
             execve()
                 │
═════════════════╪══════════════════
                 │
           Linux kernel
                 │
                 ├── validate executable
                 ├── recognise ELF
                 ├── establish new process image
                 ├── establish memory mappings
                 ├── provide argv/environment
                 └── identify ELF interpreter
                          │
══════════════════════════╪══════════
                          │
                          ▼
                dynamic linker/loader
                          │
                          ├── locate shared libraries
                          ├── load/map dependencies
                          └── prepare runtime
                          │
                          ▼
                  executable entry point
                          │
                          ▼
                    runtime startup
                          │
                          ▼
                 main(argc, argv)
                          │
                          ▼
                   application code
                          │
                 system calls as needed
                          │
                          ▼
                     termination
                          │
                          ▼
                    exit status
                          │
                          ▼
                        Bash
                          │
                     prompt returns

That is still a simplification.

It ignores threads, scheduling details, page faults, demand paging, CPU caches, interpreters, containers, security modules, capabilities, namespaces and quite a lot of runtime machinery.

But it is a much better simplification than:

“The operating system loads the program into RAM and runs it.”

What I am taking away

There are several boundaries hiding inside something as mundane as:

./hello

The shell is not the kernel. Bash interprets a command language and prepares an execution request.

A program is not a process. The executable on disk becomes the program executed within a process.

Creating a process and executing a program are different operations. Unix makes this especially visible through its process-creation and exec model: a successful execve() replaces the program image of an existing process rather than creating another one.

An executable is structured data, not just a blob of instructions. Linux understands formats such as ELF and uses their metadata to construct a process image within a virtual address space.

Memory is not simply “the program copied into RAM”. That address space contains mappings for executable code, data, libraries, stacks and other regions.

main() is not literally the first instruction executed. Executable and runtime startup happens before control reaches my C main() function.

A process inherits context. Arguments, environment variables, working directory and open file descriptors help define the environment in which the program runs.

Finishing is part of the interface too. A process produces termination information that its parent can collect, and the shell turns that into the exit status I use every day.

The command line has suddenly become much less boring.

And I suspect /proc is going to make it worse.

References and further reading

GNU Bash

Bash Reference Manual The broad description of how Bash reads, parses, expands, redirects and executes commands.

Simple Command Expansion Details how Bash turns command-line words into a command name and arguments.

Command Search and Execution Documents functions, builtins, $PATH lookup and external command execution.

Command Execution Environment Covers the environment inherited by external commands, including open files, working directory and exported variables.

Linux man-pages

execve(2) The central reference for executing a new program within an existing process.

fork(2) Documents Linux process creation through fork().

elf(5) Describes the ELF executable and linking format used by normal Linux executables and shared objects.

ld.so(8) Documents the Linux dynamic linker/loader and shared-library loading.

ldd(1) Lists shared-object dependencies and documents the caveat around using ldd on untrusted executables.

mmap(2) Describes mappings within a process’s virtual address space.

proc_pid_maps(5) Documents /proc/<pid>/maps, which exposes the mapped memory regions of a process.

environ(7) Documents the process environment made available during program execution.

getauxval(3) Explains the auxiliary vector used by the kernel’s ELF loader to communicate startup information to user space.

exit(3) Documents normal process termination and the retention of status information for a parent.

wait(2) Documents how parent processes wait for and collect state changes and termination information from children.

GNU C Library

Program Arguments Explains argc, argv and the interface presented to a C program’s main() function.

Environment Variables Explains the environment inherited by programs executed from the shell.

Executing a File Documents glibc’s exec interfaces and their relationship to arguments, environments and $PATH.