Exhibit · article form

When hello exits: who calls __cxa_finalize, and what it does

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

Step 1 / 8

A program's last act is not main returning — it is the runtime unwinding what was set up. build/hello prints one line, then tears itself down.

The guide names three of this file's calls that land in libc.so.6: puts, __libc_start_main, and __cxa_finalize. This tour follows the last one — __cxa_finalize, glibc's per-object destructor sweep — from this file's code region across the boundary into libc's own body.

Exhibit scene at step 1
Step 2 / 8

Here is the code you ran. .text is 275 bytes, 68 instructions in segment 2 (R-X). main sits at 0x1139; its call 1030 <puts@plt> at 0x1147 is the whole visible job.

Control reached main from _start, whose call *0x2f5b(%rip) # 3fc0 <__libc_start_main@GLIBC_2.34> at 0x105f handed off to libc. When main returns, it returns into that libc frame — and libc, not this file, drives the exit. Honest limit: these tools show the entry hand-off and the puts call, but not the return-through-exit instructions that eventually reach __cxa_finalize.

Exhibit scene at step 2
Step 3 / 8

The executable region carries a dedicated finalization section, .fini — 13 bytes, 4 rows, also in segment 2 (R-X). It is the counterpart to the startup .init: the place reserved for shutdown glue.

Honest limit: the guide names .fini and gives its size and row count, but does not enumerate its instructions here, so these tools do not show .fini itself reaching __cxa_finalize. The exit-time destructor walk runs through .fini_array (8 bytes) — the table of finalizers the runtime steps at teardown.

Exhibit scene at step 3
Step 4 / 8

None of that machinery has real addresses on disk — this is a PIE, so the loader patches it. .rela.dyn (8 entries, 192 bytes) is lit here, arcing to the four sections it patches: .init_array, .fini_array, .got, and .data.

Two of those matter at exit: .fini_array (the finalizer table) and .got (40 bytes) — where an imported symbol's resolved address is written for a GLOB_DAT-style binding. An imported function reached at teardown, like __cxa_finalize, is called through such a .got slot. Honest limit: these tools give the four patch targets but do not itemize which .rela.dyn/.got entry is __cxa_finalize's.

Exhibit scene at step 4
Step 5 / 8

Across the boundary now. Outside this file: __cxa_finalize lives in libc.so.6 — glibc 2.43+r22+g8362e8ce10b2-2, build-id 020d6f7c… — at address 0x40960 (264,544) in libc's own address space, symbol version GLIBC_2.2.5, 692 bytes.

Its recovered source is a single file, stdlib/cxa_finalize.c, and it folds in no inlined helpers — a self-contained routine. The Fields tab labels every number as libc's, not this file's.

Exhibit scene at step 5
Step 6 / 8

The real instructions — 179 rows, byte-canonical GNU objdump, grouped under their glibc source lines. It opens at 0x40960 endbr64 / push %rbp (line 45), then at 0x40986 takes a lock with lock cmpxchg %edx,(%r14) (line 48).

The payload is one instruction: 0x40b0a call *%rax (line 97). archive_callees classifies exactly this edge as indirect"not statically resolvable (%rax)"* — because it invokes a registered function pointer, unknowable until run time. Just before it, 0x40aec ror $0x11,%rax and 0x40af0 xor %fs:0x30,%rax (line 93) demangle that pointer. The two named callees, __lll_lock_wait_private and __lll_lock_wake_private (both GLIBC_PRIVATE), belong to the line-48/96 locking code, not the destructor path. The mainline return is 0x40a74 ret (line 122); the listing continues past it with further rows — including that call *%rax — down to its final row at 0x40c12.

Exhibit scene at step 6
Step 7 / 8

The C says what the machine does. void __cxa_finalize (void *d) (line 44) takes a DSO handle and, under __libc_lock_lock (__exit_funcs_lock) (line 48), walks for (funcs = __exit_funcs; funcs; funcs = funcs->next) (line 51). For each entry whose handle matches and whose flavor is ef_cxa (lines 55–56), it sets f->flavor = ef_free (line 91) first — so the destructor never runs twice — then PTR_DEMANGLE (cxafn) (line 93), unlocks (line 96), and runs cxafn (cxaarg, 0) (line 97): that is the call *%rax.

And when does this sweep matter? The header comment is candid (lines 36–42): this path "is specific to dlclose"; during process termination it is __run_exit_handlers that calls the registered destructors itself, and the GCC-provided __cxa_finalize calls made then "do not result in further destructor invocations." Finally, only if d != NULL, UNREGISTER_ATFORK (d) (lines 119–120) removes fork handlers.

Exhibit scene at step 7
Step 8 / 8

So: who calls __cxa_finalize when hello exits? Not main directly — and, by glibc's own comment, at process termination it is __run_exit_handlers that runs the registered destructors itself; the __cxa_finalize calls made then run no further ones. What these tools do establish: this file imports __cxa_finalize through a loader-patched .got slot, so code in this file calls it during teardown with its DSO handle. The exact trigger chain is not shown by these tools.

What it does: take the exit lock, walk the registered __cxa_atexit destructor lists, and for each match demangle the stored pointer and call it (0x40b0a call *%rax), marking the entry ef_free so it runs exactly once. That indirect call is the door out of libc and into whichever registered function the demangled pointer holds — a target beyond static resolution. Every foreign number here is libc's own (build-id 020d6f7c…), not this file's.

Exhibit scene at step 8