Exhibit · article form

From execve to main: how control reaches your code

A guided tour through build/hello, captured from the ELF Atlas exhibit (custom tour: tour.json) — 9 steps.

Step 1 / 9

When you run build/hello, the kernel's execve does not jump straight to your main. This is a ET_DYN PIE — a file that cannot even find its own addresses until something maps it into memory. So it carries instructions about how to be started.

The exhibit's own data cannot show the kernel's handoff itself — how execve maps these segments and builds the initial stack is outside what these bytes record. What the file does carry, and what we can follow byte by byte, is a chain: the interpreter it asks for, the library it needs, the entry stub the loader jumps to, and the libc routine that finally calls main.

Exhibit scene at step 1
Step 2 / 9

First link in the chain: .interp (28 bytes) holds a plain string — /lib64/ld-linux-x86-64.so.2. Program-header index 1, PT_INTERP, points at these same bytes, and the interp_requests edge lit here arcs to that interpreter.

This names the dynamic loader the kernel starts on this file's behalf. It is itself a real program: in the Reference Linux Archive, ld-linux-x86-64.so.2 (build-id 8f271b…, glibc 2.43+r22) exports 22 functions — for instance _dl_catch_exception, 457 bytes in elf/dl-catch.c. Its internals live outside this exhibit, so we cite them but cannot open them here.

Exhibit scene at step 2
Step 3 / 9

The loader's next job is written in .dynamic (480 bytes, thirty 16-byte Elf64_Dyn records). One of them is a DT_NEEDED entry naming libc.so.6 — the needs_library edge arcing out to that chip.

So before any of your code runs, the loader has a shopping list: map this file, map libc.so.6, and wire the two together. hello cannot print a character without it — the C library is where the real work lives.

Exhibit scene at step 3
Step 4 / 9

Control enters this file at e_entry = 0x1040 — the label _start at the top of .text (275 bytes, 68 instructions). _start is not your code; it is the small entry stub the toolchain supplies.

Its job is to call into libc. At 0x105f it does exactly that: call *0x2f5b(%rip) # 3fc0 <__libc_start_main@GLIBC_2.34>. Note the * — this is an indirect call. It does not name __libc_start_main directly; it reads a pointer from memory at 0x3fc0 and calls whatever that pointer holds.

Exhibit scene at step 4
Step 5 / 9

That pointer lives here: .got, the global offset table, begins at vaddr 0x3fc0 (40 bytes) — the very slot _start's indirect call reads.

Because hello is a PIE, no absolute address is known until load time, so the slot starts empty. The loader fills it by applying .rela.dyn's relocations — the reloc_patches edge lit here shows .rela.dyn patching .got (among .init_array, .fini_array, and .data). Once the relocation is applied, .got's slot at 0x3fc0 resolves to __libc_start_main, and _start's indirect call reaches libc.

Exhibit scene at step 5
Step 6 / 9

Follow the pointer and we leave the file. __libc_start_main is 336 bytes at 0x277f0 (161,776) in libc.so.6's own address space — the reference glibc 2.43+r22+g8362e8ce10b2-2 (build-id 020d6f7c…), symbol version GLIBC_2.34, recovered from csu/libc-start.c. The "Outside this file" banner means exactly that.

Watch two things it does. At entry (csu/libc-start.c:242) it saves its first argument — main — onto its stack: 0x2780e mov %rdi,-0x40(%rbp). Then, guarded by 0x27812 test %r9,%r9 (source line 311), it registers the dynamic linker's own destructor: 0x2781e call 40950 <__cxa_atexit@@GLIBC_2.2.5> (line 312) — the one direct call this function makes.

Exhibit scene at step 6
Step 7 / 9

The same routine in C — recovered csu/libc-start.c, sparse with ⋯ for unmapped lines. Its signature (line 234) takes main as the first parameter, then argc, argv, and later init, fini, rtld_fini, stack_end.

Line 312 is the __cxa_atexit((void (*)(void *)) rtld_fini, NULL, NULL) you just watched compile. Line 347 calls call_init — the constructor runner the DWARF folds inline here (declared at line 125). And the last statement, line 360, is the handoff itself: __libc_start_call_main (main, argc, argv …). (Since glibc 2.34 the legacy init pointer is always NULL, per the line 363 comment — so the (*init)() branch above is skipped.)

Exhibit scene at step 7
Step 8 / 9

Here is line 360 in machine code. __libc_start_main reloads the main it saved at entry — 0x2786a mov -0x40(%rbp),%rdi — sets %rdx to argv and %esi to argc, then at 0x27874 calls onward into __libc_start_call_main.

Be precise about honesty: the archive resolves that call target only as an internal libc address (0x276c0, no exported name), so we cite it as an address, not a name. But the source line it maps to is unambiguous — line 360, __libc_start_call_main(main, …) — and the register it loads is main. This is the last thing libc does before your code runs. (The earlier call *%r14 you may notice is the legacy (*init)() path, where %r14 holds init, not main.)

Exhibit scene at step 8
Step 9 / 9

And control lands back in this file, at your code: main at 0x1139 in .text. 0x1139 push %rbp, 0x113a mov %rsp,%rbp — the standard frame — then 0x113d lea 0xec0(%rip),%rax computes the string's address (0x1144 + 0xec0 = 0x2004) and 0x1147 call 1030 <puts@plt> prints it.

That is the whole journey. execve handed off to the interpreter named in .interp; the loader pulled in the libc.so.6 of DT_NEEDED and applied .rela.dyn so _start's indirect call through .got reached __libc_start_main; and __libc_start_main — after registering the linker's destructor — handed main, argc, and argv onward to be called. The six lines of C you wrote run only at the very end of that chain.

Exhibit scene at step 9