Loading and Startup: From ELF Entry to main

On this page

main is the application entry point called by the C runtime, while the kernel recognizes the ELF entry address. Typically, the startup object linked into the program defines _start, but the name itself holds no magic: the linker can specify a different entry, and programs not using libc can implement their own startup.

When execve succeeds, it replaces the current process's program image without creating a new PID, nor does it return to the next instruction of the old program. To understand the startup path, one must first distinguish between the kernel establishing the image and the user-space initialization of execution code. execve(2)

From execve to main: Who holds control Kernel establishes new process image Reads ELF program headers, maps the main program, and constructs the initial stack 1 If PT_INTERP exists, enter the interpreter first Maps dependencies, performs relocations, and completes early initialization 2 Jumps to the main program's ELF entry Usually _start provided by the toolchain, not main 3 C runtime completes startup Establishes arguments and initialization conventions for calling main 4 main returns, entering the normal exit flow Runs registered exit handlers and flushes stdio 5 Programs without an interpreter go directly from the kernel to the main entry; the runtime may still require initialization and self-relocation.

Who does the kernel hand control to?

The kernel establishes the mapping for the main image based on PT_LOAD, sets up the initial stack, and provides arguments, environment, and auxv. If PT_INTERP exists, it also loads the specified ELF interpreter and enters the interpreter entry first; otherwise, it starts from the main image's entry. The entry's runtime address must be combined with the load offset; e_entry cannot be treated as a file offset.

The dynamic interpreter is typically ld-linux from glibc. It needs to complete its own limited bootstrap and corresponding relocations, then handle main program dependencies, TLS, symbol resolution, and initialization. It is a special startup object; it should not be described as "needing another ld.so to run" just because it is ET_DYN.

Mapping does not mean every page is immediately read into physical memory. The first access may still trigger a page fault; page faults and I/O seen before and after main cannot be attributed solely to the source code stage. For specific loading implementations, refer to Linux v6.18 binfmt_elf.c.

exec preserves process identity, but not the old C program's context

Understanding exec as "calling another main" leads to misjudgments about resources and control flow. Heap objects, function pointers, and atexit registrations in the old address space are no longer part of the new program's execution state; the new program starts from its own startup path and does not return to the old main. Linux handles various attributes differently. execve attribute changes

StateAfter successful execImpact on program design
PIDPreservedThe same process identity can experience different program images
Old code, heap, stack, and mappingsReplacedOld addresses cannot be passed as valid pointers for the new program
File descriptorsUsually preserved, except those with FD_CLOEXECNeed to clarify which handles belong to the handover interface
Signal handlersDisposition of caught signals reset to defaultOld function addresses cannot continue to serve as handler entries
Signal maskPreservedThe new program may inherit blocked signals
Other threadsDestroyedexec does not make old threads run in parallel with the new main
Old atexit registrationsNot preserved, nor executed by execCannot rely on them for business cleanup before exec

Below, we use execv to load the target program from the same experiment directory, inheriting the environment but specifying argv anew. Save as exec-launch.c; on success, you won't see "exec failed", but on failure, the old program can usually still report errors. Linux rarely has failures that cannot recover the old program after crossing the critical point of image replacement, so "failure always returns" should not be written as an absolute guarantee.

#include <stdio.h>
#include <unistd.h>
int main(void) {
    char *args[] = {"chosen-name", "hello", NULL};
    printf("launcher pid=%ld\n", (long)getpid());
    if (fflush(stdout) == EOF) return 1;
    execv("./exec-target", args);
    perror("exec failed");
    return 1;
}

exec-target.c:

#include <stdio.h>
#include <unistd.h>
int main(int argc, char **argv) {
    printf("target pid=%ld argc=%d argv0=%s arg1=%s\n",
           (long)getpid(), argc, argc > 0 ? argv[0] : "<none>",
           argc > 1 ? argv[1] : "<none>");
    return 0;
}
cc -Wall -Wextra exec-launch.c -o exec-launch
cc -Wall -Wextra exec-target.c -o exec-target
./exec-launch

The PIDs on both lines should be the same, and the target receives argc=2, argv0=chosen-name, arg1=hello. argv[0] is set by the caller and does not reliably prove the actual path of the executable file. The launcher calls fflush first to ensure output in the old stdio buffer is written to the file descriptor; exec itself does not flush output for the old libc.

The initial stack is not a normal function call stack

Initial user stack: values, pointer arrays, and auxiliary vectors argc Number of arguments argv[0…argc-1], NULL Pointers to argument strings envp[0…], NULL Pointers to environment strings auxv: type/value pairs, AT_NULL Auxiliary information passed by the kernel Logical order shown; positions of strings, random bytes, and alignment padding are not represented by box sizes.

_start is not entered from the caller of main via a normal C call; there is no upper-level function to which one can arbitrarily ret. Startup code should read the initial stack according to the target ABI and satisfy the stack alignment required for calling C functions.

Taking native x86-64 glibc as an example, _start prepares the arguments required by __libc_start_main:

ArgumentSystem V AMD64 LocationSource and Purpose
mainrdiAddress of the application's main
argcrsiNumber of arguments in the initial stack
argvrdxPointer array of arguments in the initial stack
initrcxNew startup code passes NULL; libc finds corresponding initialization info
finir8New startup code passes NULL, not an "eighth internal flag argument"
rtld_finir9Saves the cleanup function passed by the loader
stack_endPassed on stackBoundary information related to the initial stack

Therefore, simply copying pop argc and aligning rsp, while omitting the assembly to set the main address and rcx, cannot serve as a correct startup example. glibc's x86-64 start.S also includes CFI, PIC, and handling for different data models; directly viewing the target version is more reliable than mixing source code from several generations. glibc start.S

Initialization work is scattered between the loader and libc

Basic conditions for dynamic programs, such as TLS, need to be ready before user code depending on them is executed; not all initialization can be placed into a pseudo-C function in __libc_start_main. The initialization of the main program and dependent libraries is also not an unconditional "traverse all libraries, then call init pointers" process.

In the current glibc path, the loader handles corresponding dependencies and preinit work; libc startup code then handles DT_INIT/DT_INIT_ARRAY for the main program. Old startup objects might pass initialization functions, while the init parameter for new startup objects is NULL. Dependency cycles and constructor order between different objects cannot be casually assumed based on file names or link command surface order. glibc libc-start.c

.init/.fini usually contain instructions, while .init_array/.fini_array are arrays of function pointers. C++ non-local object initialization and compiler constructor attributes can enter the execution phase before main through these mechanisms, but constructor attributes and priorities are toolchain extensions, not unified rules of ISO C.

auxv: Auxiliary information passed from kernel to runtime

KeyMeaning of Value
AT_PHDR, AT_PHENT, AT_PHNUMRuntime location, entry size, and number of the main program's program headers
AT_ENTRYMain program entry address; even if the interpreter runs first, it still describes the main program
AT_BASELoad base address of the ELF interpreter, cannot be treated as the main program base
AT_PAGESZSystem page size
AT_SYSINFO_EHDRvDSO ELF header address if available
AT_RANDOMPointer to 16 bytes of random data, available for runtime initialization of protection values
AT_SECUREWhether the loader should process environment etc. under secure execution conditions
AT_HWCAP, AT_HWCAP2Architecture-specific user-space hardware capability bits

AT_RANDOM is not a field controlling the ASLR switch. AT_SECURE is not triggered only by setuid. Reading the auxiliary vector should prioritize getauxval; inability to find it and a legitimate zero value can be distinguished by checking errno according to interface requirements. getauxval(3)

There is indeed an auxv after the initial environment pointer array, but environ during runtime may be migrated via operations like setenv, so one should not always continue scanning memory from the end of the current environ. Reading /proc/self/auxv or calling getauxval is more suitable for runtime queries.

Is the first instruction address the same as AT_ENTRY?

Look at "where the CPU jumps now" and "which object auxv describes" separately. Changing the interpreter base address changes the position of the first instruction, but does not change AT_ENTRY; this is exactly why breakpoints for starti and main fall in different stages in dynamic programs.

Preparing the visual
Is the first instruction address the same as AT_ENTRY?

When PT_INTERP exists, the interpreter runs first; AT_ENTRY is still the main program entry, and AT_BASE describes the interpreter's load base address. main is called later by user-space startup code.

Observe before and after main, not just draw arrows

Below, we use GCC/Clang's constructor and destructor extensions to observe the execution order in a main program:

#include <stdio.h>
#include <stdlib.h>
#include <sys/auxv.h>

__attribute__((constructor))
static void before_main(void) { puts("constructor"); }

__attribute__((destructor))
static void after_main(void) { puts("destructor"); }

static void at_exit(void) { puts("atexit"); }

int main(void)
{
    if (atexit(at_exit) != 0)
        return 1;
    printf("main: page size=%lu\n", getauxval(AT_PAGESZ));
    return 0;
}
cc -g -Wall -Wextra startup.c -o startup
./startup
readelf -hW -lW startup
readelf -dW startup
objdump -d --disassemble=_start startup
LD_SHOW_AUXV=1 ./startup

In this glibc example, the constructor runs before main; after main returns normally, the atexit callback and corresponding cleanup work are executed. This experiment does not prove that there is an arbitrarily dependent global initialization order between different shared libraries. The actual page size should be read from the output, do not hardcode it as 4096.

Normal return from main corresponds to libc's exit flow, which calls exit handlers and flushes stdio. _Exit/_exit skips these user-space cleanups, and process crashes or forced terminations do not guarantee they are completed. The original Linux exit system call and libc's process exit encapsulation also involve thread scope differences; see System Call ABI. exit(3)

Same file descriptor does not mean stdio buffer is automatically committed fputs("pending", stdout) Bytes temporarily stored in user-space buffer return / exit Flush buffer, execute exit handlers _Exit / successful exec Do not flush old stdio buffer File visible pending This example also has atexit marker Buffer bytes lost Kernel cannot access uncommitted bytes The diagram only discusses user-space buffers; file descriptor closure and storage device persistence are separate mechanisms.

Verify exit mode and output buffer separately

Save as exit-mode.c. Explicit full buffering ensures the experiment does not depend on whether stdout is connected to a terminal; exit handlers use standard error output for markers, facilitating separate checks from normal output.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static char output_buffer[1024];
static void finish(void) { fputs("atexit ran\n", stderr); }
int main(int argc, char **argv) {
    if (setvbuf(stdout, output_buffer, _IOFBF, sizeof output_buffer) != 0)
        return 1;
    if (atexit(finish) != 0) return 1;
    fputs("pending", stdout);
    if (argc == 2 && strcmp(argv[1], "immediate") == 0)
        _Exit(7);
    return 7;
}
cc -Wall -Wextra exit-mode.c -o exit-mode
./exit-mode > normal.out 2> normal.err
./exit-mode immediate > immediate.out 2> immediate.err

Both exit statuses should be 7. On normal return, normal.out contains pending, and normal.err has atexit ran; on immediate exit, both files are empty. If the script enables set -e, it should explicitly accept the expected non-zero status, otherwise it may stop after the first run.

This shows that exit codes and user-space cleanup are two independent pieces of information: the same status code does not prove that the same cleanup actions were executed. Normal exit flushing stdio does not equal data persisted to storage devices; programs requiring persistence semantics must arrange synchronization according to file system and interface conventions. exit(3), _exit(2)

Static linking still has startup work

Build FormCommon Entry ArrangementStill Needs Attention
Dynamic PIEPT_INTERP, interpreter runs firstDependency mapping, relocations, initialization
Static non-PIEUsually no PT_INTERP, direct main entrylibc startup, TLS, constructors, possible IFUNC
Static PIEUsually no PT_INTERP, direct main entrySelf-relocation and runtime initialization
Custom libc-free programCustom entryComply with ABI and exit protocol yourself

Static linking usually extracts needed members from archives, not unconditionally stuffing the entire libc into the file. Static programs may still contain GOT/PLT-related structures or require IFUNC relocations; static PIE explicitly needs to handle position changes. "Static means no relocations, startup is definitely ten times faster" cannot hold true; comparisons should be made under the same functionality and environment.

How to locate crashes before main

First, use readelf to confirm the main entry, interpreter, and dependencies, then use starti in gdb ./startup to stop at the first user-space instruction; dynamic programs may be in the interpreter at this point. You can then set breakpoints at main, constructors, or __libc_start_main. Whether internal symbols are visible depends on debug symbols and build methods.

strace -e trace=execve,openat,mmap,mprotect ./startup can display system calls during the loading phase, but not all user-space initialization. If dependencies are successfully mapped but main has not been reached, continue checking relocations, IFUNC, constructors, and TLS usage, rather than just staring at the first line of main.