Exhibit · article form
What happens after main calls puts
A guided tour through build/hello, captured from the ELF Atlas exhibit (custom tour: tour.json) — 9 steps.
build/hello is a 16,984-byte PIE with no printing code of its own. Its one runtime dependency arcs out to the libc.so.6 chip — the needs_library edge, read from .dynamic.
Three of this file's calls land in libc.so.6 — puts, __libc_start_main, __cxa_finalize — but only puts travels through the PLT, and it is the one that prints. Let's follow that call from main all the way into the library and watch what puts does with the string.
Here is the call itself. main sits at 0x1139 inside .text (275 bytes, 68 instructions). Two instructions set up the argument: the 7-byte lea 0xec0(%rip),%rax at 0x113d computes the string's address — 0x1144 + 0xec0 = 0x2004 — and mov %rax,%rdi loads it.
Then at 0x1147: call 1030 <puts@plt> (5 bytes). Notice the target is not puts — it is 0x1030, a stub inside this file.
That target lives in .plt — the Procedure Linkage Table, 32 bytes, 6 rows. The label puts@plt marks 0x1030; a shared resolver trampoline sits above it at puts@plt-0x10 (0x1020).
The stub's row on screen is a single jmp through a pointer — not library code. The call reaches this stub, and the stub jumps to wherever that pointer currently aims.
What fills that pointer is one relocation. .rela.plt is a single 24-byte Elf64_Rela entry, and its edge patches .got.plt (the reloc_patches link lit here) — the 32-byte slot table the .plt stub jumps through.
Because hello is a PIE, no address is known until the loader maps the file; the dynamic linker writes puts's resolved address into that .got.plt slot so the stub can find it. This one relocation is the whole binding for the program's one import.
Follow the pointer and we leave the file entirely. puts is 542 bytes at 0x83fc0 (540,608) — an address in libc.so.6's own space, not hello's.
The drawer's "Outside this file" banner means exactly that: this is a reference copy of libc.so.6 from glibc 2.43+r22+g8362e8ce10b2-2 (build-id 020d6f7c…), symbol version GLIBC_2.2.5, recovered from libio/ioputs.c. Everything past this point is a different file with its own layout.
Inside puts. The prologue at 0x83fc0 opens with endbr64 then push %rbp. The disassembly is grouped under the glibc source lines the DWARF maps it to.
Under ioputs.c:35 the source computes strlen(str); it compiles to 0x83fd6 call 24250 <*ABS*+0xaff50@plt> — a PLT thunk whose target the archive names no symbol behind. Under ioputs.c:36 puts loads the stdout FILE* (0x83fdb mov 0x18ed66(%rip),%r13 # … stdout) and takes its lock: after a __libc_single_threaded shortcut check at 0x83ff9, the uncontended acquire is 0x8401f lock cmpxchg %ecx,(%rdi).
Now the write. Before dispatching through the stream's vtable, puts validates that vtable — the inlined IO_validate_vtable check 0x8408b cmp $0x92f,%rdx (from libioP.h:1038) bounds-checks the pointer. Then, mapped to ioputs.c:40, 0x8409e call *0x38(%rax) dispatches through the stream's vtable to send the string; being an indirect call, its target is not statically resolvable.
And here is the newline. Mapped to ioputs.c:41, 0x840c0 movb $0xa,(%rax) writes byte 0x0a — '\n' — straight into the output buffer. If the buffer is full instead, the slow path at 0x841a0 mov $0xa,%esi; 0x841a5 call 915d0 <__overflow> hands that same newline to __overflow (whose own write goes through the stream vtable — beyond static resolution). puts adds the \n itself.
The whole function in C — recovered libio/ioputs.c, sparse with ⋯ for unmapped lines. _IO_puts(const char *str) reads top to bottom: line 35 strlen(str), line 36 _IO_acquire_lock(stdout), line 40 _IO_sputn(stdout, str, len), line 41 _IO_putc_unlocked('\n', stdout), on success line 42 sets result = MIN(INT_MAX, len + 1), then line 44 _IO_release_lock(stdout) and line 45 return result.
Line 41 is the C behind the newline byte you just saw. puts is static_weak_alias(_IO_puts, puts) — the name main called is an alias for this.
Back in hello, the loop closes. .rodata holds "hello, world" at 0x2004 — 12 bytes, and no trailing newline. The programmer wrote printf("…\n"); the compiler, seeing a plain string with a newline, rewrote it as puts("…") and dropped the \n from the data.
That missing byte is not lost — it is line 41 of ioputs.c, the movb $0xa we watched puts write at run time. The file stores twelve characters; the thirteenth is supplied by the library on every successful write — line 41 only runs once _IO_sputn has delivered all twelve.