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:
- The System V AMD64 ABI
- The ELF object and executable format
- 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
_startentry point - Reads
argc,argv, andenvpfrom the kernel-created stack - Preserves the initial process state where necessary
- Enforces ABI-required stack alignment
- Walks
.init_arrayand invokes constructors - Calls
main()according to the System V AMD64 calling convention - Implements an
atexit-style handler mechanism - Walks
.fini_arrayand invokes destructors - Terminates through the Linux
exitsystem 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:
RSP ──► argc
argv[0]
argv[1]
...
argv[argc - 1]
NULL
envp[0]
envp[1]
...
NULL
auxiliary vector entriesThe 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:
.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
syscallThere 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:
argcis stored at[rsp].argvbegins at[rsp + 8].argvcontainsargcstring pointers.- A null pointer terminates the argument list.
envpbegins immediately after that null pointer.
Conceptually:
argv = initial_rsp + 8
envp = argv + (argc + 1) pointersAn assembly implementation may derive envp as follows:
mov rdi, [rsp] # argc
lea rsi, [rsp + 8] # argv
lea rdx, [rsi + rdi*8 + 8] # envpThe three conventional arguments to a three-parameter entry function are then:
RDI = argc
RSI = argv
RDX = envpThe 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:
int main(int argc, char **argv);Under the System V AMD64 ABI, its arguments are passed in registers:
RDI = argc
RSI = argvFor an extended environment-aware form:
int main(int argc, char **argv, char **envp);the registers are:
RDI = argc
RSI = argv
RDX = envpThe return value is placed in EAX.
A custom runtime therefore requires no special language mechanism to invoke main(). It needs only:
- Correct argument registers
- Correct stack alignment
- A standard
callinstruction - 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:
__init_array_start
__init_array_endThe runtime can walk the array and call each function pointer:
.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:
retThis 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:
__fini_array_start
__fini_array_endFinalization functions are commonly executed in reverse order so that cleanup mirrors initialization.
A simplified implementation is:
.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:
retBy 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:
.section .bss
.align 8
exit_handler_count:
.quad 0
exit_handlers:
.skip 8 * 32A registration routine stores a function pointer:
.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
retDuring 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:
RAX = system-call number
RDI = argument 1
RSI = argument 2
RDX = argument 3
R10 = argument 4
R8 = argument 5
R9 = argument 6The syscall is issued using:
syscallThe return value is placed in RAX.
A direct write operation may be implemented as:
.global runtime_write
runtime_write:
mov eax, 1 # sys_write
syscall
retIts arguments follow the Linux syscall convention:
RDI = file descriptor
RSI = buffer address
RDX = byte countProgram termination uses syscall number 60:
.global runtime_exit
runtime_exit:
mov eax, 60 # sys_exit
syscall
ud2 # should never executeThe 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:
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 callEvery stage must follow the ABI and preserve the state required by later stages.
Runtime Skeleton
A simplified integrated runtime may look like this:
.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
syscallThis 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:
- CPU architecture
- Assembly-language fundamentals
- Calling conventions and the ABI
- Linux system calls
- Process creation
- Virtual memory
- Dynamic linking
- ELF inspection
- Kernel-boundary rules
- Performance engineering
- 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
_startrather thanmain() - How the initial stack is structured
- How
argc,argv, andenvpare 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.