_start and __libc_start_main

main is not the entry point of a C program. The ELF entry point — the address in e_entry that the kernel or the dynamic linker jumps to — is _start, and between _start and the first instruction of main sits __libc_start_main, the C library function that turns a raw execution environment into a working C program. _start, supplied by the crt startup object (crt1.o/Scrt1.o), is thirteen instructions of hand-written assembly: it reads argc and argv off the initial stack, zeroes the frame pointer, re-aligns the stack, and calls __libc_start_main, passing main and a set of housekeeping pointers. __libc_start_main does the real work — it caches the auxiliary vector, parses tunables, probes CPU features, sets up thread-local storage and the thread pointer, derives the stack-smashing canary from the kernel’s AT_RANDOM bytes, registers teardown handlers, runs the executable’s constructors, calls main(argc, argv, envp), and passes main’s return value to exit(). It never returns to _start (start.S, glibc 2.43; libc-start.c, glibc 2.43). That ordering is the reason a constructor that touches a __thread variable behaves differently from one that does not, and the reason a hand-rolled -nostdlib entry point segfaults the first time it reads %fs:0 — both demonstrated with real output below. This note traces both halves; the crt object files that supply _start are covered in The C Runtime Startup Files crt.

Authority for this note. Every source-level claim is pinned to glibc 2.43 (read from sourceware.org at refs/tags/glibc-2.43 — the bminor/glibc GitHub mirror has no 2.43 tag) and to the x86-64 psABI. Every measured claim was produced on the host described in the Demonstrating It sections: Fedora 44, kernel 7.1.13, glibc 2.43, GCC 16.2.1, binutils 2.46.1, AMD Ryzen AI MAX+ 395. Where glibc’s own comments contradict each other, the note says so rather than picking one.

Mental Model

There is a chasm between what the kernel provides and what C code assumes. The kernel provides a stack with argc/argv/envp/auxv on it, an instruction pointer, and nothing else — no thread pointer, so errno and thread-local variables have nowhere to live; no stack canary, so the compiler’s stack-protector would read garbage; no initialized libc globals; and no registered destructors, so atexit would have nowhere to record handlers. __libc_start_main is the bridge across that chasm. _start is just the on-ramp: it adapts the kernel’s stack-based calling convention to the register-based C convention and hands off.

The single most important idea is the division of labor: _start is deliberately minimal and per-architecture (it must run before any C environment exists, so it cannot be C), while __libc_start_main is portable C that does everything that can be done in C. And the second most important idea is the hand-off to exit: main returning is not the end of the program — its return value flows into exit(), which runs the whole teardown path.

sequenceDiagram
    participant K as kernel execve
    participant D as ld.so (_dl_start)
    participant S as _start (Scrt1.o)
    participant L as __libc_start_main
    participant M as main
    participant X as exit
    K->>D: jump to PT_INTERP e_entry<br/>%rsp -> argc, %rdx -> rtld_fini
    D->>D: map DSOs, relocate,<br/>_dl_init: preinit_array + dependency ctors
    D->>S: jump to executable e_entry
    S->>S: endbr64, xor %ebp, %rdx -> %r9,<br/>pop argc, argv -> %rdx, realign %rsp
    S->>L: call __libc_start_main(main, argc, argv,<br/>NULL, NULL, rtld_fini, stack_end)
    Note over L: STATIC-ONLY BLOCK (#ifndef SHARED)
    L->>L: __environ, __libc_stack_end, _dl_aux_init(auxv)
    L->>L: __tunables_init, ARCH_INIT_CPU_FEATURES
    L->>L: _dl_relocate_static_pie, ARCH_SETUP_IREL
    L->>L: ARCH_SETUP_TLS: allocate TCB, set %fs base
    rect rgb(220, 245, 220)
    Note over L,M: TLS, errno and %fs:0x28 are live from here on
    end
    L->>L: _dl_setup_stack_chk_guard -> %fs:0x28
    L->>L: _dl_setup_pointer_guard -> %fs:0x30
    Note over L: BOTH BUILDS
    L->>L: __cxa_atexit(rtld_fini)
    L->>L: call_init: DT_INIT + DT_INIT_ARRAY<br/>of the MAIN EXECUTABLE
    L->>M: __libc_start_call_main -> main(argc, argv, envp)
    M-->>L: int return value
    L->>X: exit(return value)
    Note over X: __run_exit_handlers: dtors, _dl_fini,<br/>stdio flush, then the _exit syscall

The _start → __libc_start_main → main → exit ritual for a dynamically linked executable, per glibc 2.43 csu/libc-start.c. What it shows: the dynamic linker runs first and handles DT_PREINIT_ARRAY and every dependency’s constructors, then jumps to _start; _start adapts the stack to the register calling convention; __libc_start_main establishes TLS and the guards (in the static build only — in a dynamic build ld.so did it already), registers teardown, runs the main executable’s own constructors, and only then calls main. The insight to take: the green band is the whole point. Everything above it runs with %fs pointing at nothing useful; everything below it — including every __attribute__((constructor)) in your program — can touch errno, __thread variables, and the stack canary. Code that straddles that line is where “it crashes before main with no backtrace” comes from.

The same information as a checklist of what is and is not usable at each stage. The “not yet safe” column is the operational content: it is the list of things that will fail silently or crash if you call them from a hand-written entry point or from a constructor placed too early.

StageWho runs itWhat becomes availableWhat is still not safe to use
kernel execve → e_entrykernel load_elf_binarystack with argc/argv/envp/auxv; %rdx = rtld_fini; [vdso] mappedeverything: no %fs base, no .data relocation for a PIE, no libc state
ld.so relocation_dl_start / _dl_mainall DSOs mapped and relocated; the initial TLS block and TCB allocated; %fs base setthe executable’s own constructors have not run
_dl_initelf/dl-init.cDT_PREINIT_ARRAY of the executable, then each dependency’s DT_INIT/DT_INIT_ARRAYthe executable’s own .init_array (deliberately skipped here — see below)
_startScrt1.o/crt1.oargument registers loaded, stack re-aligned, %rbp zeroed, cfi_undefined(rip) setnothing new; this is 13 instructions of plumbing
__libc_start_main early (static build only)csu/libc-start.c__environ, __libc_stack_end, _dl_aux_init, tunables, CPU features, static-PIE self-relocation, IRELATIVE resolutionTLS is set up inside this block — before ARCH_SETUP_TLS() nothing thread-local works
ARCH_SETUP_TLS()__libc_setup_tlsTCB allocated, %fs base installed → errno, __thread, %fs:0x28 slot existthe canary value is still zero for one more step
canary + pointer guard_dl_setup_stack_chk_guard, _dl_setup_pointer_guard%fs:0x28 and %fs:0x30 hold real random values from AT_RANDOMstdio is not initialised yet
__libc_early_init / __libc_init_firstcsu/init-first.c__libc_multiple_libcs, program name, stdio bootstrapatexit handlers for the program are not registered yet
__cxa_atexit(rtld_fini) (+ call_fini when static)csu/libc-start.cteardown path armed—
call_initcsu/libc-start.c (dynamic: walks the main map’s DT_INIT_ARRAY)the executable’s __attribute__((constructor)) functions run, in .init_array ordermain has not run; anything a constructor expects main to have set is still unset
mainyour codeeverything—
exitstdlib/exit.c__run_exit_handlers walks the registered list newest-first: destructors, _dl_fini, _IO_cleanupafter _exit(2) nothing runs

Stage-by-stage view of the same sequence. What it shows: which component owns each step, and — in the last column — the precondition each step establishes for the next. The insight to take: TLS availability is the sharp edge. It is established roughly two-thirds of the way through __libc_start_main’s static-build prologue, and everything before it (tunables parsing, CPU-feature probing, static-PIE self-relocation) is written in a deliberately TLS-free, relocation-free style precisely because it runs on the wrong side of that line.

_start — the psABI entry contract

The x86-64 psABI is unusually terse about process entry, and that terseness is the point: only three registers have defined values, everything else is garbage, and all the actual data arrives on the stack. Quoting the specification’s Initial Stack and Register State section directly (x86-64 psABI, low-level-sys-info.tex):

Register / stateValue at process entryWhy _start cares
%rsp“the address of the byte with lowest address which is part of the stack. It is guaranteed to be 16-byte aligned at process entry.” In practice this is &argc.Every argument to __libc_start_main except rtld_fini is dug out of here
%rdx“a function pointer that the application should register with atexit” — this is rtld_fini, the dynamic linker’s finalizerMoved straight to %r9 (6th argument) before anything can clobber it
%rbp“unspecified … but the user code should mark the deepest stack frame by setting the frame pointer to zero”xor %ebp, %ebp — makes frame-pointer unwinders stop here
every other GPRunspecifiedCannot be read; %rax in particular is used as push-padding, not data
rFLAGSDF=0 (direction forward), CF=PF=AF=ZF=SF=OF=0String instructions may assume forward direction without a cld
x87 FPCWRC=0 (round to nearest), PC=11 (double-extended), all exception masks setlibm can assume a sane default FPU environment
MXCSRFZ=0 (no flush-to-zero), RC=0, DAZ=0 (denormals are not zero)SSE code gets IEEE-correct denormal handling by default

The complete x86-64 process-entry contract. What it shows: the specification defines three registers, the floating-point control state, and the stack; nothing else. The insight to take: _start exists because C’s calling convention is register-based and the kernel’s hand-off is stack-based. Those seven lines of the psABI are the entire interface between the kernel and every userspace language runtime on the platform — Go, Rust and a -nostdlib C program all start from exactly this state.

The stack itself, per the psABI’s Initial Process Stack figure, is four null-terminated vectors laid end to end, growing toward higher addresses away from %rsp. Real offsets, measured on this box with ./stackmap one two (source in Demonstrating It below):

block-beta
  columns 3
  a["LOW ADDRESSES<br/>(stack grows down into here)"]:3
  b1["%rsp + 0x00"] b2["long argc"] b3["measured: 0x7ffd25a99f40, value 3"]
  c1["%rsp + 0x08"] c2["char *argv[0..argc-1]"] c3["-> 0x7ffd25a9ba3f &quot;./stackmap&quot;"]
  d1["%rsp + 8+8*argc"] d2["NULL"] d3["argv terminator, at +0x20"]
  e1["%rsp + 8+8*(argc+1)"] e2["char *envp[0..n-1]"] e3["at +0x28, 117 entries here"]
  f1["after envp"] f2["NULL"] f3["envp terminator"]
  g1["%rsp + 0x3d8"] g2["Elf64_auxv_t auxv[]<br/>(a_type, a_un.a_val) pairs"] g3["22 entries x 16 bytes"]
  h1["end of auxv"] h2["AT_NULL entry"] h3["auxv terminator"]
  i["Information block: the argument strings, environment strings and<br/>AT_RANDOM / AT_PLATFORM bytes the pointers above point into"]:3
  j["HIGH ADDRESSES (top of the [stack] VMA)"]:3
  style a fill:#eee,stroke:#999
  style j fill:#eee,stroke:#999
  style b2 fill:#ffe8cc
  style g2 fill:#cce5ff

The initial process stack at _start, with real addresses from a run of stackmap one two on this host. What it shows: four consecutive NULL-terminated regions — argv, envp, auxv — anchored at %rsp, followed by the information block holding the strings they point into. There is no length field anywhere: argc bounds argv, and everything after it is found by walking to the next NULL. The insight to take: the auxiliary vector’s position is not a constant — it is &argc + 8 + 8*(argc+1) + 8*(nenv+1), which is why _dl_aux_init cannot be handed a pointer and must walk past envp’s NULL to find it (while (*evp++ != NULL); in csu/libc-start.c). At +0x3d8 in this run, the auxv sat almost a kilobyte past %rsp purely because the shell exported 117 environment variables.

_start translates this state into a call to __libc_start_main, whose signature in glibc 2.43 is:

STATIC int
LIBC_START_MAIN (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
                 int argc, char **argv,
                 __typeof (main) init,     /* legacy constructor runner — always NULL now */
                 void (*fini) (void),      /* legacy destructor runner — completely unused */
                 void (*rtld_fini) (void), /* ld.so's finalizer, arrived in %rdx */
                 void *stack_end);         /* see the note on "stack_end" below */

Here is the actual x86-64 assembly at glibc 2.43, which differs from the description usually given in two ways that matter (start.S, glibc 2.43). Disassembled from a PIE binary built on this host with GCC 16.2.1:

0000000000000390 <_start>:
 390:  f3 0f 1e fa     endbr64                        # CET landing pad (indirect jump target)
 394:  31 ed           xor    %ebp,%ebp               # psABI: mark the outermost frame
 396:  49 89 d1        mov    %rdx,%r9                # rtld_fini  -> arg 6
 399:  5e              pop    %rsi                    # argc       -> arg 2  (%rsp now &argv[0])
 39a:  48 89 e2        mov    %rsp,%rdx               # argv       -> arg 3
 39d:  48 83 e4 f0     and    $0xfffffffffffffff0,%rsp  # re-align: pop left %rsp at 8 mod 16
 3a1:  50              push   %rax                    # 8 bytes of deliberate garbage
 3a2:  54              push   %rsp                    # arg 7 (stack_end), passed on the stack
 3a3:  45 31 c0        xor    %r8d,%r8d               # fini = NULL -> arg 5
 3a6:  31 c9           xor    %ecx,%ecx               # init = NULL -> arg 4
 3a8:  48 8d 3d d1 ff ff ff  lea -0x2f(%rip),%rdi     # &main -> arg 1  (relaxed from main@GOTPCREL)
 3af:  ff 15 0b 2c 00 00     call *0x2c0b(%rip)       # __libc_start_main@GLIBC_2.34
 3b5:  f4              hlt                            # unreachable: the callee never returns

First correction: the alignment sequence is not a bare and. The psABI guarantees %rsp is 16-byte aligned at entry; pop %rsi then leaves it at 8 mod 16. and $-16 rounds it back down to 0 mod 16, push %rax pushes eight bytes of intentional garbage (the source comment is literally “Push garbage because we push 8 more bytes”), and push %rsp pushes the seventh argument. That leaves %rsp at 0 mod 16 immediately before the call, so that on entry to __libc_start_main — after the call has pushed an 8-byte return address — %rsp + 8 is 16-aligned, which is exactly what the ABI requires of a called function. Describing this as “masks %rsp to align it” skips the part that actually passes an argument.

Second correction: what stack_end means. push %rsp on x86-64 pushes the value of %rsp before the decrement, so stack_end is the address of the garbage %rax slot — i.e. essentially the entry %rsp, rounded. glibc’s two comments about it contradict each other: start.S says “Provide the highest stack address to the user code (for stacks which grow downwards)”, while csu/libc-start.c says “Store the lowest stack address.” They are describing the same number from opposite framings — it is the lowest address currently part of the in-use stack (the psABI’s own wording for %rsp) and simultaneously the highest address the stack will ever occupy, because it grows downward from there. Measured on this host, __libc_stack_end came out at 0x7ffdf14c05f0 inside a [stack] VMA spanning 0x7ffdf14a1000–0x7ffdf14c4000 — 14 KB below the top of the VMA (the information block sits above it) and 126 KB above the bottom. It is unambiguously near the high end.

The remaining details are as usually described. endbr64 is the Intel CET (Control-flow Enforcement Technology) landing pad required because e_entry is reached by an indirect transfer. init and fini are zeroed because, since glibc 2.34, they are vestigial: the header comment in libc-start.c states “The init and fini parameters are no longer used. fini is completely unused, init is still called if not NULL, but the current startup code always passes NULL.” Before 2.34 they were __libc_csu_init and __libc_csu_fini from csu/elf-init.c (elf-init.c at glibc 2.33 — the file is 404 at the 2.43 tag, deleted outright). The lea -0x2f(%rip),%rdi above is the linker having relaxed the source’s main@GOTPCREL(%rip) into a direct PC-relative address, legal because main is defined in the same link unit; in a non-PIE build on this same host the instruction is instead mov $0x400380,%rdi, an absolute immediate. The object-file details — crt1.o vs Scrt1.o, Mcrt1.o, gcrt1.o — live in The C Runtime Startup Files crt.

One more line the usual description omits: immediately before xor %ebp, %ebp the source emits cfi_undefined (rip), with the comment “Clearing frame pointer is insufficient, use CFI.” Zeroing %rbp stops frame-pointer-based unwinders; marking the return address column undefined in the DWARF Call Frame Information stops .eh_frame-based unwinders, which is the mechanism libgcc’s unwinder and backtrace(3) actually use. Both are needed to make _start a genuine terminator.

__libc_start_main — the initialization ritual, step by step

Inside __libc_start_main (which on modern glibc delegates the final main call to a helper, __libc_start_call_main), the operations run in a deliberate order, each establishing a precondition the next relies on (libc-start.c, glibc 2.40):

The first thing to understand about this function is that it is compiled twice, differently. csu/libc-start.c opens with the comment “Note: This code is only part of the startup code proper for statically linked binaries. For dynamically linked binaries, it resides in libc.so.” The whole early-initialisation block — environment, auxv, tunables, CPU features, static-PIE self-relocation, TLS, canary, pointer guard — sits inside #ifndef SHARED. In a dynamically linked program none of it runs here, because ld.so did all of it before _start was ever reached. Only the last third of the function is common to both builds.

With that established, here is the exact glibc 2.43 order, read top to bottom from LIBC_START_MAIN in csu/libc-start.c:

The #ifndef SHARED prologue — static builds only

  1. Locate the environment and record the stack end. char **ev = &argv[argc + 1]; __environ = ev; — the environment array begins one word past argv’s NULL terminator (see The Initial Process Stack). Then __libc_stack_end = stack_end, with the comment “This is done in ld.so if this is the code for the DSO” — an explicit statement that the dynamic path assigns the same variable elsewhere.

  2. Find and parse the auxiliary vector. Because the psABI does not hand anyone a pointer to the auxv, glibc walks to it: char **evp = ev; while (*evp++ != NULL); auxvec = (ElfW(auxv_t) *) evp;. Then _dl_aux_init(auxvec) caches the entries libc needs — notably _dl_random (the AT_RANDOM pointer), AT_PAGESZ, AT_HWCAP, AT_PHDR — both for the steps below and for later getauxval(3) calls. AT_PHDR matters immediately: the source comment says “First process the auxiliary vector since we need to find the program header to locate an eventually present PT_TLS entry.” Contents and semantics of the vector are The Auxiliary Vector’s subject.

  3. Tunables. __tunables_init(__environ) parses GLIBC_TUNABLES from the environment. This must precede CPU-feature detection because tunables such as glibc.cpu.hwcaps can mask features the CPU actually has.

  4. CPU features. ARCH_INIT_CPU_FEATURES() (on x86-64, init_cpu_features() via CPUID) fills the cpu_features structure that IFUNC resolvers consult to pick an AVX2 or AVX-512 memcpy — see GNU IFUNC.

  5. Static-PIE self-relocation. _dl_relocate_static_pie(). The comment is the interesting part: “Do static pie self relocation after tunables and cpu features are setup for ifunc resolvers. Before this point relocations must be avoided.” A statically linked PIE has no ld.so, so until this line runs, every absolute pointer in the binary’s own data is still un-rebased — which is why steps 1–4 are written to touch only stack values and immediates. See Relocation Processing.

  6. ARCH_SETUP_IREL(). Applies R_X86_64_IRELATIVE relocations, running each IFUNC resolver and patching in the chosen implementation.

  7. ARCH_SETUP_TLS() — the line everything hinges on. Expands to __libc_setup_tls(), which finds the PT_TLS program header, allocates the initial thread’s TLS block plus the Thread Control Block (TCB), and sets the thread-pointer register (%fs on x86-64, via arch_prctl(ARCH_SET_FS)). The in-source comment gives the reason it is here and not later: “The stack guard goes into the TCB, so initialize it early.” Before this line, errno — which is __thread int — cannot be read or written, and any __thread access is a load from an unset segment base. TLS mechanics are Thread-Local Storage and The Thread Control Block.

  8. ARCH_APPLY_IREL(). A second IFUNC hook, deliberately after TLS, because on some architectures (the comment names powerpc) the resolvers want to read hwcap and platform fields that live in the TCB.

  9. The stack canary. uintptr_t stack_chk_guard = _dl_setup_stack_chk_guard (_dl_random); then THREAD_SET_STACK_GUARD (stack_chk_guard), which on x86-64 stores it at %fs:0x28. The compiler’s -fstack-protector prologue copies that word onto the frame and the epilogue compares it; a mismatch calls __stack_chk_fail. glibc zeroes the guard’s least-significant byte so that a strcpy-style overflow, which stops at a NUL, cannot cheaply leak or forge it. Measured on this host — the identity is confirmed, not assumed:

    $ ./guard
    %fs:0x00 (TCB self ptr) = 0x00007fcd4f5cb740
    %fs:0x28 (stack canary) = 0xc2b4615d88987800
    %fs:0x30 (pointer guard)= 0x2a5804728f9d4326
    AT_RANDOM[0..7] LE      = 0xc2b4615d8898787d   (low byte zeroed -> 0xc2b4615d88987800)
    canary == AT_RANDOM[0..7] with low byte zeroed? YES
    

    Note that %fs:0x30, the pointer guard, is not derived from the same eight bytes — _dl_setup_pointer_guard(_dl_random, stack_chk_guard) takes a second argument and draws on the remaining AT_RANDOM bytes. The relationship between AT_RANDOM and %fs:0x28 is owned by The Auxiliary Vector; it is reproduced here only because it is the concrete output of this step.

  10. __pthread_initialize_minimal() if it is non-NULL, then the pointer guard (_dl_setup_pointer_guard → THREAD_SET_POINTER_GUARD, %fs:0x30), used by PTR_MANGLE/PTR_DEMANGLE to obfuscate function pointers stored in longjmp buffers and atexit records.

The common tail — both builds

  1. Register the dynamic linker’s finalizer. if (__glibc_likely (rtld_fini != NULL)) __cxa_atexit ((void (*) (void *)) rtld_fini, NULL, NULL); — this is outside the #ifndef SHARED guard and runs in both builds. It is the first entry on the exit-handler list, which is walked newest-first, so it runs last. See atexit and Process Teardown.

  2. Early libc init (static only). __libc_early_init (true), then __libc_init_first (argc, argv, __environ) — which on Linux sets __libc_multiple_libcs, stores __libc_argc/__libc_argv/__environ, and calls __init_misc to derive program_invocation_name (csu/init-first.c, glibc 2.43). Then __cxa_atexit (call_fini, NULL, NULL) registers the static build’s .fini_array runner, and if __libc_enable_secure is set, __libc_check_standard_fds() refuses to start a set-user-ID binary whose fds 0/1/2 are closed — a mitigation against the classic “open a file and it becomes stdout” privilege-escalation trick.

  3. Run the executable’s constructors — in both builds. call_init (argc, argv, __environ). This is the step most descriptions get wrong, and it is the subject of the next section.

  4. Audit hook and hand-off. _dl_audit_preinit(...) fires the la_preinit rtld-audit checkpoint, then __libc_start_call_main (main, argc, argv) calls main(argc, argv, envp) and passes the result to exit(). return 0; from main is exactly exit(0). Because exit ends the process, __libc_start_main is declared __attribute__((noreturn)) and the hlt after the call in _start is unreachable (exit.c, glibc 2.43).

Two orderings in that list are worth memorising because they are load-bearing and non-obvious: tunables before CPU features (a tunable can mask a feature), and static-PIE relocation before anything that dereferences a pointer from the binary’s own data (there is no ld.so to have done it).

Who actually runs your constructors

The widely repeated claim — including in the earlier revision of this note — is that __libc_start_main runs .init_array only for statically linked programs, and that for a dynamic binary _dl_init already ran everything. That is wrong at glibc 2.43, and it has been wrong since 2.34. The file defines call_init twice:

#ifdef SHARED
/* Initialization for dynamic executables.  Find the main executable
   link map and run its init functions.  */
static void
call_init (int argc, char **argv, char **env)
{
  struct link_map *l = GL(dl_ns)[LM_ID_BASE]._ns_loaded;
 
  /* DT_PREINIT_ARRAY is not processed here.  It is already handled in
     _dl_init in elf/dl-init.c. ... */
 
  if (ELF_INITFINI && l->l_info[DT_INIT] != NULL)
    DL_CALL_DT_INIT(l, l->l_addr + l->l_info[DT_INIT]->d_un.d_ptr, argc, argv, env);
 
  ElfW(Dyn) *init_array = l->l_info[DT_INIT_ARRAY];
  if (init_array != NULL) { /* … call each pointer with (argc, argv, env) … */ }
}
#else /* !SHARED */
static void
call_init (int argc, char **argv, char **envp)
{
  /* For static executables, preinit happens right before init.  */
  for (i = 0; i < __preinit_array_end - __preinit_array_start; i++)
    (*__preinit_array_start[i]) (argc, argv, envp);
# if ELF_INITFINI
  _init ();
# endif
  for (size_t i = 0; i < __init_array_end - __init_array_start; i++)
      (*__init_array_start[i]) (argc, argv, envp);
}
#endif

The two differ in where they find the array, not in whether they run it. The static version uses the linker-provided __init_array_start/__init_array_end boundary symbols. The dynamic version walks the main executable’s link_map and reads DT_INIT_ARRAY out of its dynamic section — exactly what the deleted __libc_csu_init used to do with boundary symbols, relocated into libc.

The other half of the mechanism is in elf/dl-init.c. _dl_init iterates the search list and calls its own call_init(l, …) for each object — but that function begins with an explicit skip:

  /* Check for object which constructors we do not run here.  */
  if (__builtin_expect (l->l_name[0], 'a') == '\0'
      && l->l_type == lt_executable)
    return;

The main executable is the one link_map whose l_name is the empty string. _dl_init therefore handles DT_PREINIT_ARRAY and every dependency’s constructors and then deliberately declines to run the executable’s own — leaving that job to __libc_start_main. The division is clean, and directly observable. The test program below calls backtrace(3) + dladdr(3) from four places and prints which object each caller lives in:

$ ./exe
EXE preinit_array        <- ?@exe <- ?@ld-linux-x86-64.so.2 <- ?@ld-linux-x86-64.so.2
SHLIB ctor               <- ?@libdemo.so <- ?@ld-linux-x86-64.so.2 <- ?@ld-linux-x86-64.so.2
EXE ctor prio 101        <- ?@exe <- __libc_start_main@libc.so.6 <- _start@exe
EXE ctor no prio         <- ?@exe <- __libc_start_main@libc.so.6 <- _start@exe
main                     <- main@exe <- ?@libc.so.6 <- __libc_start_main@libc.so.6

Read the object names in the right-hand columns. The preinit function and the shared library’s constructor are called from ld-linux-x86-64.so.2 — that is _dl_init. The executable’s own constructors, both of them, are called from __libc_start_main in libc.so.6, whose caller is _start in the executable. And main’s caller is an unnamed function in libc.so.6 — that is __libc_start_call_main, which readelf -sW /lib64/libc.so.6 confirms is a LOCAL symbol (4: 0000000000003600 271 FUNC LOCAL DEFAULT 4 __libc_start_call_main), which is why dladdr cannot name it.

flowchart TD
  START["execve, then ld.so takes control"] --> DLINIT["_dl_init(main_map, argc, argv, env)<br/>elf/dl-init.c"]
  DLINIT --> PRE["DT_PREINIT_ARRAY<br/>of the MAIN EXECUTABLE only"]
  PRE --> LOOP{"for each object in<br/>main_map->l_searchlist,<br/>dependencies first"}
  LOOP --> SKIP{"l_name[0] == 0<br/>AND l_type == lt_executable?"}
  SKIP -->|"yes: this IS the<br/>main executable"| RET["return — deliberately<br/>run NOTHING here"]
  SKIP -->|"no: a dependency .so"| DEP["DT_INIT then DT_INIT_ARRAY<br/>of that shared object"]
  DEP --> LOOP
  RET --> ENTRY["jump to executable e_entry = _start"]
  ENTRY --> LSM["__libc_start_main<br/>csu/libc-start.c"]
  LSM --> CI{"compiled with SHARED?"}
  CI -->|"yes (dynamic)"| CID["call_init: read DT_INIT +<br/>DT_INIT_ARRAY from the main map's<br/>dynamic section"]
  CI -->|"no (static)"| CIS["call_init: preinit_array, _init,<br/>then __init_array_start..end<br/>via linker boundary symbols"]
  CID --> MAIN["main(argc, argv, envp)"]
  CIS --> MAIN
  style RET fill:#ffd9d9,stroke:#c33
  style CID fill:#d9f0d9,stroke:#3a3
  style CIS fill:#d9f0d9,stroke:#3a3

Who runs which constructor array, per glibc 2.43. What it shows: _dl_init owns DT_PREINIT_ARRAY and every dependency’s constructors, then hits an explicit early-return for the main executable (red); __libc_start_main picks that job up afterwards (green), in both the static and dynamic builds, differing only in where it finds the array. The insight to take: the red box is the whole subtlety. The main executable is visited by _dl_init’s loop like every other object and is then deliberately skipped, which is why “the dynamic linker runs all the constructors” is a plausible-sounding claim that the source contradicts in three lines.

The practical consequence: if you set a breakpoint in a constructor of the main executable and walk up the stack, you will land in libc, not in ld.so. A profiler that attributes your executable’s constructor time to libc.so rather than to the dynamic linker is behaving correctly. The reverse is true for anything in a .so you link against.

.preinit_array, .init_array, .fini_array and constructor priority

The three arrays are ELF-standard: DT_PREINIT_ARRAY, DT_INIT_ARRAY and DT_FINI_ARRAY, each with a matching …SZ giving the byte size (System V gABI, ch. 5 Dynamic Section; elf(5)). Their contract:

Section / tagWhen it runsRun by (dynamic exe)Argument signatureOrderAllowed in a shared object?
.preinit_array / DT_PREINIT_ARRAYbefore any constructor, including dependencies’_dl_init (elf/dl-init.c)(int argc, char **argv, char **envp)array orderNo — the gABI restricts it to the executable
.init / DT_INIT (legacy _init)after preinit, before .init_array__libc_start_main’s call_init via DL_CALL_DT_INIT(argc, argv, envp)single functionyes
.init_array / DT_INIT_ARRAYafter DT_INIT__libc_start_main’s call_init(int argc, char **argv, char **envp)forward, index 0 firstyes (run by _dl_init)
.fini_array / DT_FINI_ARRAYat exit, before .fini_dl_fini (dynamic) / call_fini registered with __cxa_atexit (static)(void)reverse, last index firstyes
.fini / DT_FINI (legacy _fini)lastsame(void)single functionyes

Two details in that table are routinely missed. First, .init_array entries are called with argc, argv and envp — a constructor declared void f(void) works only because the extra arguments sit in registers it ignores; declare it static void f(int argc, char **argv, char **envp) and it receives the real thing. Second, .fini_array runs backwards, which is what makes constructor/destructor pairs nest correctly.

Priority is a GCC feature layered on top, implemented purely through section naming and a linker sort. Per the GCC manual, the priority “is a constant integral expression bounded between 101 and 65535 inclusive; priorities 0-100 are reserved for use by the compiler and its runtime libraries. A constructor with a smaller priority number runs before a constructor with a larger priority number; the opposite relationship holds for destructors… without an argument, the priority argument is equivalent to 65535” (GCC Common Function Attributes). Compiling a file with five constructors at different priorities on GCC 16.2.1 and reading back the section headers confirms the mechanism exactly:

$ readelf -SW prio.o | grep init_array
  [ 4] .init_array.00101 INIT_ARRAY  …  000008  … constructor(101)
  [ 6] .init_array.01000 INIT_ARRAY  …  000008  … constructor(1000)
  [ 8] .init_array.65534 INIT_ARRAY  …  000008  … constructor(65534)
  [10] .init_array       INIT_ARRAY  …  000010  … constructor(65535) AND bare constructor
What you writeSection GCC emitsEffect
__attribute__((constructor(100)))rejectedwarning: constructor priorities from 0 to 100 are reserved for the implementation [-Wprio-ctor-dtor]
__attribute__((constructor(101))).init_array.00101runs first among prioritised
__attribute__((constructor(1000))).init_array.01000sorted numerically by the zero-padded suffix
__attribute__((constructor(65534))).init_array.65534last prioritised entry
__attribute__((constructor(65535)))plain .init_arrayidentical to no priority — 65535 is GCC’s default
__attribute__((constructor))plain .init_arrayruns after every prioritised entry, in link order

The sort is done by the linker script, not the loader. ld -Map on the demo binary shows the two input patterns in the order the default script applies them:

.init_array     0x0000000000402d98       0x20
 *(SORT_BY_INIT_PRIORITY(.init_array.*) SORT_BY_INIT_PRIORITY(.ctors.*))
 .init_array.00101   0x0000000000402d98   0x8  /tmp/ccLUevi3.o
 *(.init_array EXCLUDE_FILE(*crtend?.o *crtbegin?.o) .ctors)
 .init_array         0x0000000000402da0   0x8  /usr/lib/gcc/x86_64-redhat-linux/16/crtbegin.o
 .init_array         0x0000000000402da8  0x10  /tmp/ccLUevi3.o

All .init_array.NNNNN inputs, sorted numerically, come first; then everything in plain .init_array, in link order. That is why an unprioritised constructor can never beat a prioritised one, and why crtbegin.o’s frame_dummy lands between them. Dumping the finished array confirms it:

$ readelf -x .preinit_array ctor
  0x00402d90 47064000 00000000                   G.@.....      -> 0x400647  preinit
$ readelf -x .init_array ctor
  0x00402d98 c9054000 00000000 10054000 00000000  -> 0x4005c9 c101, 0x400510 frame_dummy
  0x00402da8 da054000 00000000 eb054000 00000000  -> 0x4005da c65535, 0x4005eb cdef
$ readelf -x .fini_array ctor
  0x00402db8 25064000 00000000 e0044000 00000000  -> 0x400625 d101, 0x4004e0 __do_global_dtors_aux
  0x00402dc8 36064000 00000000                    -> 0x400636 ddef

…and running it confirms both the forward init order and the reverse fini order:

$ ./ctor
PREINIT array fn       caller chain: <- ? <- ? <- ?
ctor prio 101          caller chain: <- ? <- __libc_start_main <- _start
ctor prio 65535        caller chain: <- ? <- __libc_start_main <- _start
ctor no prio           caller chain: <- ? <- __libc_start_main <- _start
    TLS readable in ctor? tlsvar=0x1234  &tlsvar=0x7fde59d9577c
main()  __libc_stack_end = 0x7ffdf14c05f0
  [stack] VMA: 7ffdf14a1000-7ffdf14c4000 rw-p 00000000 00:00 0   [stack]
dtor no prio
dtor prio 101

c65535 was declared constructor(65535) and ran after c101 but in the same plain-.init_array bucket as the unprioritised one — the measurement that proves 65535 really is “no priority” rather than “lowest priority”. And the destructors mirror: ddef (later in the array) ran before d101 (earlier in the array), because .fini_array is walked backwards.

Note also the line TLS readable in ctor? tlsvar=0x1234 — a __thread variable read successfully from inside a constructor. That is the payoff of TLS being established before step 13: your constructors are on the safe side of the line. The next section shows what happens on the other side. .init_array semantics in their own right are Init Array Constructors and Destructors’s subject.

Demonstrating It: a _start with no libc underneath it

The cleanest way to see what __libc_start_main provides is to remove it. This program is built -nostdlib -nostartfiles -static: no crt1.o, no libc, no dynamic linker, its own _start, and raw syscall instructions for write and exit. It reads the initial stack exactly as _start does, walks past argv and envp to the auxiliary vector, prints a few entries — and then reads %fs:0.

static __thread int tls_probe = 0xABCD;   /* needs a thread pointer nobody set up */
 
__asm__(".globl _start\n"
        "_start:\n"
        "  xor %ebp, %ebp\n"
        "  mov %rsp, %rdi\n"      /* pass the raw stack pointer as arg 1 */
        "  and $-16, %rsp\n"
        "  call _start_c\n"
        "  hlt\n");
 
void _start_c(long *sp) {
  long argc = sp[0];
  char **argv = (char **)&sp[1];
  char **e = argv + argc + 1; while (*e) e++;
  Elf64_auxv_t *av = (Elf64_auxv_t *)(e + 1);
  /* … walk av, print AT_RANDOM / AT_SYSINFO_EHDR / AT_PAGESZ / AT_HWCAP / AT_ENTRY … */
  unsigned long fs0;
  __asm__ volatile ("mov %%fs:0, %0" : "=r"(fs0));   /* the moment of truth */
  /* … never reached … */
}
$ gcc -O1 -nostdlib -nostartfiles -static -o bare bare.c && ./bare ; echo "exit status: $?"
hand-written _start, no libc:
  argc     = 0x0000000000000001
  argv[0]  @ 0x00007fff73562a53
  &argc(%rsp on entry) = 0x00007fff73560f10
  AT_RANDOM       = 0x00007fff73561449
  AT_SYSINFO_EHDR = 0x00007f1756ef8000
  AT_PAGESZ       = 0x0000000000001000
  AT_HWCAP        = 0x00000000178bfbff
  AT_ENTRY        = 0x00000000004001c0
exit status: 139

Everything the kernel provides works perfectly: argc, argv, the environment, the whole auxiliary vector, the [vdso] base in AT_SYSINFO_EHDR, and the write(2)/exit(2) system calls used to print it. What is missing is everything libc provides. Exit status 139 is 128 + 11 — SIGSEGV — raised at the single instruction mov %fs:0, %rax, because arch_prctl(ARCH_SET_FS, …) was never called and the %fs base is zero. The program never reached the tls_probe read on the next line.

That one fault is the whole thesis of this note in a single data point. The kernel hands you a complete process. It does not hand you a C environment. errno is __thread int, so it lives behind %fs; the stack canary lives at %fs:0x28; pthread_self() is %fs:0x10. All three are the same fault. Add __libc_setup_tls() — or just link against crt1.o and let __libc_start_main run — and every one of them starts working at the same instant.

Uncertain

Verify: the behaviour of the static-only prologue (steps 1–10 above) as executed, rather than as read. Reason: this host has no glibc-static package (/usr/bin/ld.bfd: cannot find -lc: have you installed the static version of the c library?), so every static-linkage claim in this note is read from glibc 2.43 source rather than observed. The dynamic-linkage claims — who calls the constructors, priority ordering, the canary identity, the %fs fault — are all measured. To resolve: install glibc-static and re-run the ./exe backtrace demo as gcc -static, expecting the constructor callers to become __libc_start_main with no ld.so frames at all. uncertain

The __libc_start_call_main split (glibc 2.34+)

Since glibc 2.34 the call to main has been factored out of __libc_start_main into __libc_start_call_main, defined per-sysdeps in libc_start_call_main.h. The generic version is a one-liner:

_Noreturn static __always_inline void
__libc_start_call_main (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
                        int argc, char **argv MAIN_AUXVEC_DECL)
{
  exit (main (argc, argv, __environ MAIN_AUXVEC_PARAM));
}

The version actually used on Linux is sysdeps/nptl/libc_start_call_main.h, and it does something the usual “it exists for unwinding correctness” explanation understates. It allocates a struct pthread_unwind_buf on the stack, setjmps into it, installs it as the main thread’s cancellation buffer, and then calls main:

  struct pthread_unwind_buf unwind_buf;
  not_first_call = setjmp ((struct __jmp_buf_tag *) unwind_buf.cancel_jmp_buf);
  if (__glibc_likely (! not_first_call))
    { struct pthread *self = THREAD_SELF; /* … chain unwind_buf, call main … */ }
  else
    {
      __nptl_deallocate_tsd ();
      /* … */
      if (atomic_fetch_add_relaxed (&__nptl_nthreads, -1) != 1)
        while (1) INTERNAL_SYSCALL_CALL (exit, 0);   /* exit the THREAD, not the process */
    }
  exit (result);

The else branch is reached by longjmp, and the thing that jumps there is pthread_exit() called from main. That is a legal and slightly exotic operation: it must terminate the initial thread without terminating the process if other threads are still running. __libc_start_call_main is the frame that makes it work — it is the landing pad for the unwind, it releases thread-specific data, decrements __nptl_nthreads, and only if the count hits zero does it fall through to exit(result). If other threads remain it issues the raw exit system call (which ends one thread) in a loop instead. A C++ exception escaping main terminates at this frame for the same structural reason.

The function is deliberately not exported. readelf -sW /lib64/libc.so.6 on this host (glibc 2.43) shows:

     4: 0000000000003600   271 FUNC    LOCAL  DEFAULT    4 __libc_start_call_main
  1838: 0000000000003710   336 FUNC    GLOBAL DEFAULT    4 __libc_start_main@@GLIBC_2.34
  1840: 0000000000003710   336 FUNC    GLOBAL DEFAULT    4 __libc_start_main@GLIBC_2.2.5
  4365: 0000000000003710   336 FUNC    LOCAL  DEFAULT    4 __libc_start_main_impl

Note the two versions of __libc_start_main at the same address: @@GLIBC_2.34 is the default that new binaries link against, and @GLIBC_2.2.5 is the compat alias for binaries built before the init/fini parameters became vestigial. (On x86-64 the base version is GLIBC_2.2.5, not the GLIBC_2_0 that libc-start.c’s SHLIB_COMPAT macro nominally names — the architecture’s oldest version wins.) Both alias __libc_start_main_impl, which is itself LOCAL.

Failure Modes and Common Misunderstandings

“main is the entry point.” main is late. _start → __libc_start_main → call_init → your constructors all precede it. A crash inside a constructor produces a core dump whose backtrace never reaches main, and a gdb start command (which breaks on main) will sail straight past the bug. Use starti, which breaks at e_entry, or break _dl_init.

Uninitialized TLS/errno in a hand-rolled _start. Demonstrated above: exit status 139 at mov %fs:0, %rax. A -nostdlib program that provides its own _start but skips __libc_setup_tls will crash the moment any code touches errno or a __thread variable. The symptom is a SIGSEGV at an address suspiciously close to zero (%fs base 0 plus a small offset), which looks like a null-pointer dereference and is diagnosed as one for an embarrassingly long time.

Stack-protector false failures. If the canary slot is never initialised, the -fstack-protector epilogue compares against whatever is at %fs:0x28 and the program aborts in __stack_chk_fail for no apparent reason. Because the canary is per-process (derived once from AT_RANDOM, not re-rolled per thread), a clone(2) that forgets to copy the TCB produces the same failure in the child only.

Expecting __libc_csu_init/__libc_csu_fini. Tooling or tutorials that look up these symbols are describing pre-2.34 glibc. On glibc ≥ 2.34 they do not exist — csu/elf-init.c, the file that defined them, is 404 at the 2.43 tag, and _start passes zeros for init/fini. Setting a breakpoint on __libc_csu_init on a modern system simply never fires, and exploit write-ups that rely on a __libc_csu_init gadget chain (the “ret2csu” technique) do not apply to binaries linked against glibc 2.34 or newer.

“__libc_start_main does not run constructors for a dynamic binary — _dl_init did.” The most common error about this function, and measurably false: see Who actually runs your constructors above. _dl_init explicitly skips the main executable (if (l->l_name[0] == '\0' && l->l_type == lt_executable) return;) and __libc_start_main’s own SHARED build of call_init runs it. What _dl_init does own is DT_PREINIT_ARRAY and every dependency.

Assuming stack_end is the bottom of the stack. It is the high end. glibc’s own two comments disagree about the wording; the measurement above settles it. Code that treats __libc_stack_end as a lower bound and walks upward will walk off the end of the stack VMA.

Assuming .init_array entries take no arguments. They are called with (argc, argv, envp). A constructor that needs the command line does not have to stash it from main — it is already in the registers.

Alternatives and When This Differs

musl. musl’s split is cleaner than glibc’s and differs in one way that can bite you. Its crt/crt1.c is C, not assembly — the per-architecture shim in crt_arch.h does nothing but hand the raw stack pointer to _start_c(long *p), which unpacks it:

void _start_c(long *p)
{
	int argc = p[0];
	char **argv = (void *)(p+1);
	__libc_start_main(main, argc, argv, _init, _fini, 0);
}

Note that musl still passes _init and _fini, but __libc_start_main’s parameters for them are literally named init_dummy and fini_dummy and are never used; the real work is done by libc_start_init, which calls the weak file-scope _init() and then walks the array (musl 1.2.5 src/env/__libc_start_main.c). Its __init_libc performs the same sequence glibc does — cache auxv, __init_tls(aux), __init_ssp(aux[AT_RANDOM]), then the set-user-ID standard-fd check — in about forty lines. The portability trap: musl calls .init_array entries with no arguments:

	for (; a<(uintptr_t)&__init_array_end; a+=sizeof(void(*)()))
		(*(void (**)(void))a)();

A constructor written as static void f(int argc, char **argv, char **envp), which is well-defined on glibc, reads three garbage registers under musl. Write constructors as void f(void) if the binary may ever be built against musl. See glibc vs musl and The C Runtime Startup Files crt.

Other language runtimes replace the upper half, not the lower. A Go binary’s _start is _rt0_amd64_linux, which transfers to the runtime’s rt0_go bootstrap rather than to __libc_start_main; it reads the same stack per the same psABI contract and then builds goroutine stacks, the g0/m0 pair and the scheduler instead of a single-threaded C environment (see Static and Dynamic Linking in Go). Rust keeps glibc’s _start and hooks in at lang_start, a function the main shim calls, so the entire ritual described here runs unchanged beneath it.

Freestanding. A -nostartfiles -nostdlib program supplies its own _start and does none of this — no TLS, no canary, no constructors, no atexit, no stdio — and must call the exit system call directly, as the bare demo above does. Appropriate for OS kernels, shellcode, init-less containers and very small static tools; the price is the SIGSEGV shown above the first time anything touches %fs.

Production Notes

__libc_start_main is one of the most-interposed functions in the ecosystem. LD_PRELOAD tooling and several fuzzers (notably AFL’s forkserver and libFuzzer’s driver) hook it to get control before main while still having a fully initialised libc underneath — a strictly better place to hook than a constructor, because by the time __libc_start_main is entered the dynamic linker has finished and every dependency’s constructor has run, but the executable’s own constructors have not.

Diagnosing “it crashes before main.” Because the whole ritual precedes main, the standard debugger reflex is wrong. gdb’s start command breaks on main and therefore runs straight through the bug; use starti, which stops at e_entry — the first instruction of _start — or break on _dl_init to catch the dependency constructors. LD_DEBUG=all (which drives the GLRO(dl_debug_mask) & DL_DEBUG_IMPCALLS branches visible in the source above) prints calling init:, calling preinit:, initialize program: and transferring control: lines that bracket each stage, and is usually faster than stepping. The three common root causes are a constructor that blocks on something not yet available (a lock, a network socket, another library’s constructor), a TLS-model mismatch (an initial-exec __thread variable in a dlopened library), and a static-PIE relocation issue.

The GLIBC_2.34 version wall. A binary linked on a glibc-2.34-or-newer host records a dependency on __libc_start_main@GLIBC_2.34 — visible in the disassembly above. Copied to a host with an older libc it fails at load time with version 'GLIBC_2.34' not found (required by ./prog), before a single instruction of the program runs, and no amount of LD_LIBRARY_PATH fixes it. This is the single most common “works on my machine” failure when shipping a dynamically linked binary built on a recent distribution, and it is a direct consequence of the csu rework described here. See Versioned Symbols in glibc.

Reverse engineering. _start is the reliable anchor in a stripped binary: it is at e_entry, it is always a dozen-odd instructions, and the value it loads into %rdi immediately before call *__libc_start_main is the address of main. In the PIE disassembly above that is lea -0x2f(%rip),%rdi; in a non-PIE build it is mov $0x400380,%rdi. Every decompiler’s “find main” heuristic is this pattern. The __libc_csu_init gadget chain (“ret2csu”), long a staple of return-oriented-programming write-ups for controlling %rdx on x86-64, relies on a function that no longer exists in glibc ≥ 2.34 — a good example of a mitigation arriving as a side effect of a cleanup rather than as a security change.

Constructors are not free and not ordered across translation units. Priority only orders constructors within a link, and only relative to other prioritised ones; across shared objects the order is the dependency order _dl_init computes, and circular dependencies are explicitly unspecified — elf/dl-init.c’s comment on the matter (“Stupid users forced the ELF specification to be changed… Stupidity rules!”) is unusually candid about how load-bearing and how fragile this is. Static initialisation order problems in C++ are the same bug wearing different clothes; the robust fix is a function-local static (lazy init) rather than a priority number.

See Also