Building a Minimal User-Space Runtime on Linux

Execution Without Hidden Infrastructure

The purpose of this volume is to reduce Linux user-space execution to its essential components and reveal, with absolute precision:

  • What the kernel provides when a program begins executing in user mode
  • What the program must do to execute correctly
  • How the System V AMD64 ABI governs program startup
  • How ELF metadata controls initialization and finalization
  • How Linux system calls provide the final interface to the kernel

Beneath compilers, linkers, runtime libraries, startup files, dynamic loaders, and language abstractions lies a simple and deterministic execution contract based on three foundations:

  1. The System V AMD64 ABI
  2. The ELF object and executable format
  3. The Linux system-call interface

This booklet exposes that contract by constructing a complete user-space runtime directly in x86-64 assembly.

What Normally Happens Before main()

Almost every C or C++ program silently depends on infrastructure that most developers rarely examine.

Before main() executes:

  • The compiler may insert runtime helpers.
  • The linker includes startup object files.
  • The dynamic loader maps shared libraries.
  • External symbols are resolved.
  • Thread-local storage is prepared.
  • Global runtime state is initialized.
  • Static constructors are executed.
  • Destructors and termination handlers are registered.
  • Command-line arguments and environment data are prepared.
  • Control is finally transferred to main().

For most developers, this machinery remains invisible until it must be replaced, customized, debugged, or removed.

This book replaces it.

Constructing the Runtime Manually

The runtime developed in this volume is written entirely in pure x86-64 assembly using GNU Assembler (GAS) with Intel syntax.

It operates without:

  • libc
  • Dynamic linking
  • Compiler-generated startup code
  • Compiler runtime helpers
  • Stack unwinding
  • Exception-handling infrastructure
  • PLT indirection
  • GOT-based symbol access
  • Hidden initialization routines
  • Automatic termination support

Every required runtime operation is implemented explicitly.

The runtime:

  • Defines its own _start entry point
  • Reads argc, argv, and envp from the kernel-created stack
  • Preserves the initial process state where necessary
  • Enforces ABI-required stack alignment
  • Walks .init_array and invokes constructors
  • Calls main() according to the System V AMD64 calling convention
  • Implements an atexit-style handler mechanism
  • Walks .fini_array and invokes destructors
  • Terminates through the Linux exit system call

Nothing is delegated to a standard runtime library.

Assembly as the Implementation

Assembly is not used merely to illustrate runtime behavior. It is the complete implementation.

Every operation is visible:

  • Initial stack decoding
  • Argument recovery
  • Environment traversal
  • Register preparation
  • Stack alignment
  • Constructor dispatch
  • Function invocation
  • Return-value handling
  • Exit-handler execution
  • Destructor dispatch
  • System-call execution
  • Process termination

Every instruction executed by the processor is intentional and available for inspection.

This makes it possible to understand the exact transition from kernel-created process state to a functioning C or C++ program.

The Kernel Entry Point

When Linux begins executing a new process, it transfers control directly to the executable’s entry point, commonly named _start.

It does not call main().

At entry, the stack contains the initial process information prepared by the kernel.

A simplified layout is:

TEXT
RSP ──► argc
        argv[0]
        argv[1]
        ...
        argv[argc - 1]
        NULL
        envp[0]
        envp[1]
        ...
        NULL
        auxiliary vector entries

The first value is the argument count. It is followed by pointers to the argument strings, a null pointer, environment-variable pointers, another null pointer, and the ELF auxiliary vector.

The runtime must interpret this layout correctly.

Minimal _start Implementation

A minimal entry point can recover the command-line arguments, call main(), and terminate through a raw Linux system call:

ASM
.intel_syntax noprefix

.global _start
.extern main

.section .text

_start:
    # Initial stack layout:
    #
    # [rsp + 0]   = argc
    # [rsp + 8]   = argv[0]
    # [rsp + 16]  = argv[1]
    # ...
    # argv[argc]  = NULL
    # followed by envp

    mov rdi, [rsp]          # rdi = argc
    lea rsi, [rsp + 8]      # rsi = &argv[0]

    # Align the stack before calling main.
    and rsp, -16

    call main               # int main(int argc, char** argv)

    # main() returns its status in eax.
    mov edi, eax            # edi = process exit status
    mov eax, 60             # Linux x86-64 sys_exit
    syscall

There is no hidden prologue.

No runtime prepares the environment.

The kernel transfers control directly to _start, and everything that follows becomes the program’s responsibility.

Recovering argc, argv, and envp

At process entry:

  • argc is stored at [rsp].
  • argv begins at [rsp + 8].
  • argv contains argc string pointers.
  • A null pointer terminates the argument list.
  • envp begins immediately after that null pointer.

Conceptually:

TEXT
argv = initial_rsp + 8
envp = argv + (argc + 1) pointers

An assembly implementation may derive envp as follows:

ASM
mov rdi, [rsp]              # argc
lea rsi, [rsp + 8]          # argv

lea rdx, [rsi + rdi*8 + 8]  # envp

The three conventional arguments to a three-parameter entry function are then:

TEXT
RDI = argc
RSI = argv
RDX = envp

The runtime must recover these values before modifying the original stack pointer.

Stack Alignment

The System V AMD64 ABI defines strict stack-alignment rules for function calls.

Before executing a call instruction, the caller must prepare the stack so the callee receives the alignment expected by the ABI.

Correct alignment matters because called functions may rely on it for:

  • Local stack storage
  • SIMD operations
  • Register spills
  • Aligned temporary buffers
  • Compiler-generated stack accesses

A custom runtime cannot assume that alignment will be repaired automatically.

It must calculate and enforce the required alignment explicitly.

Failure to do so may cause:

  • Incorrect execution
  • Crashes in generated code
  • Misaligned SIMD accesses
  • ABI incompatibility
  • Difficult-to-diagnose runtime failures

Calling main()

A conventional C entry function may be declared as:

C
int main(int argc, char **argv);

Under the System V AMD64 ABI, its arguments are passed in registers:

TEXT
RDI = argc
RSI = argv

For an extended environment-aware form:

C
int main(int argc, char **argv, char **envp);

the registers are:

TEXT
RDI = argc
RSI = argv
RDX = envp

The return value is placed in EAX.

A custom runtime therefore requires no special language mechanism to invoke main(). It needs only:

  1. Correct argument registers
  2. Correct stack alignment
  3. A standard call instruction
  4. Correct handling of the returned status

ELF Initialization Arrays

C and C++ programs may contain functions that must execute before main().

Modern ELF systems commonly represent these functions through .init_array.

The linker provides boundaries such as:

TEXT
__init_array_start
__init_array_end

The runtime can walk the array and call each function pointer:

ASM
.extern __init_array_start
.extern __init_array_end

run_init_array:
    lea rbx, [rip + __init_array_start]
    lea r12, [rip + __init_array_end]

.init_loop:
    cmp rbx, r12
    jae .init_done

    mov rax, [rbx]
    add rbx, 8

    test rax, rax
    jz .init_loop

    call rax
    jmp .init_loop

.init_done:
    ret

This mechanism allows static constructors and initialization routines to execute without assistance from libc.

The runtime is responsible for:

  • Locating the array
  • Determining its boundaries
  • Reading each function pointer
  • Preserving required registers
  • Maintaining stack alignment
  • Invoking each valid entry

ELF Finalization Arrays

Functions that must execute during normal termination may appear in .fini_array.

The linker may expose:

TEXT
__fini_array_start
__fini_array_end

Finalization functions are commonly executed in reverse order so that cleanup mirrors initialization.

A simplified implementation is:

ASM
.extern __fini_array_start
.extern __fini_array_end

run_fini_array:
    lea rbx, [rip + __fini_array_start]
    lea r12, [rip + __fini_array_end]

.fini_loop:
    cmp r12, rbx
    jbe .fini_done

    sub r12, 8
    mov rax, [r12]

    test rax, rax
    jz .fini_loop

    call rax
    jmp .fini_loop

.fini_done:
    ret

By walking this array manually, the runtime can execute destructors without relying on standard startup or termination code.

Implementing atexit from First Principles

A complete runtime may also provide a minimal atexit-style mechanism.

The implementation requires:

  • Storage for registered function pointers
  • A counter tracking the number of handlers
  • A registration function
  • Capacity validation
  • Reverse-order execution during shutdown

A fixed-size table provides a deterministic implementation:

ASM
.section .bss
.align 8

exit_handler_count:
    .quad 0

exit_handlers:
    .skip 8 * 32

A registration routine stores a function pointer:

ASM
.global runtime_atexit

runtime_atexit:
    mov rax, [rip + exit_handler_count]
    cmp rax, 32
    jae .registration_failed

    lea rcx, [rip + exit_handlers]
    mov [rcx + rax*8], rdi

    inc rax
    mov [rip + exit_handler_count], rax

    xor eax, eax
    ret

.registration_failed:
    mov eax, -1
    ret

During shutdown, handlers are executed in reverse registration order.

This reproduces the essential behavior of atexit() without dynamic allocation or library support.

Direct Linux System Calls

Without libc, interaction with the operating system must occur through the Linux syscall ABI.

On x86-64 Linux:

TEXT
RAX = system-call number
RDI = argument 1
RSI = argument 2
RDX = argument 3
R10 = argument 4
R8  = argument 5
R9  = argument 6

The syscall is issued using:

ASM
syscall

The return value is placed in RAX.

A direct write operation may be implemented as:

ASM
.global runtime_write

runtime_write:
    mov eax, 1              # sys_write
    syscall
    ret

Its arguments follow the Linux syscall convention:

TEXT
RDI = file descriptor
RSI = buffer address
RDX = byte count

Program termination uses syscall number 60:

ASM
.global runtime_exit

runtime_exit:
    mov eax, 60             # sys_exit
    syscall

    ud2                     # should never execute

The kernel’s exit system call is the final and authoritative process-termination interface.

A Complete Startup Sequence

A more complete runtime follows a deliberate sequence:

TEXT
Kernel transfers control to _start
                ↓
Preserve the initial stack pointer
                ↓
Recover argc, argv, and envp
                ↓
Align the stack
                ↓
Run .init_array constructors
                ↓
Call main(argc, argv, envp)
                ↓
Preserve main's return value
                ↓
Run registered exit handlers
                ↓
Run .fini_array destructors
                ↓
Invoke the Linux exit system call

Every stage must follow the ABI and preserve the state required by later stages.

Runtime Skeleton

A simplified integrated runtime may look like this:

ASM
.intel_syntax noprefix

.global _start
.extern main
.extern run_init_array
.extern run_exit_handlers
.extern run_fini_array

.section .text

_start:
    # Preserve initial stack address.
    mov r12, rsp

    # Recover process arguments.
    mov rdi, [r12]              # argc
    lea rsi, [r12 + 8]          # argv
    lea rdx, [rsi + rdi*8 + 8]  # envp

    # Preserve arguments across initialization.
    mov r13, rdi
    mov r14, rsi
    mov r15, rdx

    # Establish ABI-compliant alignment.
    and rsp, -16

    # Execute constructors.
    call run_init_array

    # Restore arguments for main().
    mov rdi, r13
    mov rsi, r14
    mov rdx, r15

    call main

    # Preserve main's return value.
    mov ebx, eax

    # Execute registered termination handlers.
    call run_exit_handlers

    # Execute destructors.
    call run_fini_array

    # Terminate the process.
    mov edi, ebx
    mov eax, 60
    syscall

This skeleton replaces the core responsibilities normally handled by standard runtime startup objects.

What This Approach Reveals

Removing the standard runtime provides a direct understanding of:

  • How the kernel constructs a process’s initial execution context
  • How the initial user stack is organized
  • How command-line arguments are represented
  • How environment variables are located
  • How the auxiliary vector follows the environment
  • How the ABI governs register usage
  • How the stack must be aligned
  • How function calls are performed
  • How ELF metadata controls constructors and destructors
  • How system calls cross the kernel boundary
  • How process termination actually occurs

Mechanisms that normally appear automatic become visible and understandable.

libc as a Choice

Once a minimal runtime can be constructed manually, higher-level runtime mechanisms become optional rather than conceptually mandatory.

libc becomes:

A powerful convenience library rather than a fundamental requirement for Linux execution.

Likewise:

  • Startup objects become implementation choices.
  • Dynamic linking becomes a deployment choice.
  • PLT and GOT indirection become linking strategies.
  • Compiler helpers become optional dependencies.
  • Automatic initialization becomes replaceable logic.
  • Standard termination becomes a runtime policy.

This does not make those facilities unnecessary. It makes their purpose and behavior understandable.

Why Minimalism Matters

The objective is not minimalism for its own sake.

The goal is minimalism for correctness.

A minimal runtime exposes:

  • Every assumption
  • Every register transition
  • Every stack adjustment
  • Every function call
  • Every initialization stage
  • Every cleanup stage
  • Every kernel interaction

Hidden behavior is reduced, making the runtime easier to inspect, verify, and adapt.

The result should perform exactly what the kernel and ABI require:

No more, and no less.

Applications

A manually constructed runtime can serve as a foundation for:

  • Custom execution environments
  • Embedded Linux runtimes
  • Statically linked tools
  • Specialized loaders
  • Language-specific runtimes
  • Research operating systems
  • Security experiments
  • Sandboxed processes
  • Minimal containers
  • Bare runtime prototypes
  • Compiler backend research
  • Educational systems software

It also provides a practical foundation for understanding how conventional runtimes are designed.

Relationship to the Series

This final booklet completes a progression through the major layers of low-level Linux execution:

  1. CPU architecture
  2. Assembly-language fundamentals
  3. Calling conventions and the ABI
  4. Linux system calls
  5. Process creation
  6. Virtual memory
  7. Dynamic linking
  8. ELF inspection
  9. Kernel-boundary rules
  10. Performance engineering
  11. Runtime construction

The runtime developed here integrates these subjects into a complete execution model.

Understanding _start requires knowledge of ELF.

Calling main() requires knowledge of the ABI.

Executing constructors requires knowledge of linker metadata.

Interacting with Linux requires knowledge of system calls.

Managing the stack requires knowledge of x86-64 architecture.

The runtime is therefore both a teaching instrument and a practical synthesis of the entire series.

Technical Standards

All examples in this volume:

  • Target Linux on x86-64
  • Use GNU Assembler
  • Use Intel syntax
  • Follow the System V AMD64 ABI
  • Use explicit Linux system calls
  • Avoid hidden runtime dependencies
  • Enforce correct stack alignment
  • Preserve registers according to ABI rules
  • Treat ELF metadata explicitly
  • Favor deterministic execution

The chapters maintain a strict focus on low-level correctness and architectural precision.

Final Objective

By the end of this volume, the reader will understand:

  • What happens when the kernel begins executing a user-space program
  • Why the entry point is _start rather than main()
  • How the initial stack is structured
  • How argc, argv, and envp are recovered
  • How ABI-compliant function calls are constructed
  • How constructors and destructors are executed
  • How exit handlers can be implemented
  • How system calls work without libc
  • How a program terminates through the kernel
  • How a complete minimal runtime can be built in assembly

Program startup is no longer an invisible sequence managed by external infrastructure. It becomes a precise, inspectable, and deliberately controlled execution process.

Understanding this runtime means understanding the essence of Linux user-space execution: how a program moves from a kernel-created process image to initialized code, executes its main logic, performs cleanup, and returns control permanently to the operating system.