The question
Both of these programs do the same thing: they run and return 0. One is a
teaching demo (build/hello, a one-line printf); the other is
/usr/bin/true, the coreutils tool whose entire job is to exit successfully.
Yet on disk:
| file size | provenance | |
|---|---|---|
| hello | 16,984 bytes (0x4258) | exhibit-001-elf-atlas/fixtures/hello.json → file.size |
| /usr/bin/true | 43,248 bytes (0xa8f0) | …/rooms/true/true.json → file.size |
true is 2.5× the size. Why?
The one-line spoiler: true's executable code alone — its .text section,
19,027 bytes — is larger than the entire hello file (16,984). The gap
isn't packaging or padding. It's that true is a real, well-behaved,
user-facing tool and hello is a demo. Let's watch that happen, section by
section.
You'll drive both rooms by hand. Every configuration of a room lives in
the URL hash (#v=1&view=…&scale=…&select=…&tab=…), so each step below gives you
two clickable links that land the two tabs on the same kind of view. Open them
side by side.
- hello base:
https://helloscope-live.iunknown.workers.dev/rooms/hello/ - true base:
https://helloscope-live.iunknown.workers.dev/rooms/true/
Step 1 — The whole file, honestly scaled
Set both tabs to atlas view at true scale, where every structure's height is strictly proportional to its byte count.
- hello → https://helloscope-live.iunknown.workers.dev/rooms/hello/#v=1&view=atlas&scale=true
- true → https://helloscope-live.iunknown.workers.dev/rooms/true/#v=1&view=atlas&scale=true
What you see: two columns of very different length. In hello, no single
band dominates — the file is mostly tables, padding, and DWARF debug info.
In true, one band swallows the column: .text.
Now open the section header table in each — it's an array, and its length is the section count:
- hello → https://helloscope-live.iunknown.workers.dev/rooms/hello/#v=1&view=atlas&scale=true&select=section%20header%20table
- true → https://helloscope-live.iunknown.workers.dev/rooms/true/#v=1&view=atlas&scale=true&select=section%20header%20table
The first surprise: hello has 37 sections; true has only 28. The
bigger file has fewer sections. That's because hello is an unstripped
teaching build carrying seven .debug_* DWARF sections, while true is a
stripped distro binary — its debug info was split out (you'll find a lone
.gnu_debuglink pointing at a separate file). So the size delta is not debug
metadata. Hold that thought.
Step 2 — The code mass (.text)
Open the executable segment (Segment 2, R-X) in each and disassemble .text:
- hello → https://helloscope-live.iunknown.workers.dev/rooms/hello/#v=1&view=ribbons&scale=map&examine=1&select=.text&tab=disasm
- true → https://helloscope-live.iunknown.workers.dev/rooms/true/#v=1&view=ribbons&scale=map&examine=1&select=.text&tab=disasm
What you see: hello's .text is 275 bytes — 68 rows in the Disasm tab,
and you can scroll the whole function past in a second. main is at 0x1139;
five instructions later it calls puts and returns. That's the program.
true's .text is 19,027 bytes — 4,637 rows. You can scroll for a long
time. This is not one function; it's argument parsing, --help/--version
handling, locale setup, and the coreutils "close stdout and check for write
errors" shutdown dance — all compiled in.
What it means: 19,027 > 16,984. true's code by itself outweighs the
entire hello file. Everything from here just explains what all that code is
for.
(Provenance: .text sizes from each fixture's sections[]; row counts are the
Disasm-tab rows in disassembly[".text"] — 68 and 4,637 respectively.)
Step 3 — What the program has to say (Strings tab)
Open .rodata's strings in each:
- hello → https://helloscope-live.iunknown.workers.dev/rooms/hello/#v=1&view=ribbons&scale=map&select=.rodata&tab=strings
- true → https://helloscope-live.iunknown.workers.dev/rooms/true/#v=1&view=ribbons&scale=map&select=.rodata&tab=strings
What you see: hello's .rodata is 17 bytes and holds exactly one
string — "hello, world" at offset 0x2004 (no trailing newline; puts adds
it). That's the whole read-only payload.
true's .rodata is 2,927 bytes with 46 strings, and reading them tells
you what the program is: --help, --version, GNU coreutils,
https://www.gnu.org/software/coreutils/, bug-coreutils@gnu.org, license and
"Written by" boilerplate. None of this executes; all of it exists because a
real tool must be able to describe itself to a human.
What it means: the difference between a demo and a tool starts showing up in
the data, not just the code — true carries a small manual.
Step 4 — Who they call (library chips + foreign drawer)
Open the libc.so.6 overview in each — the list of libc functions the binary
actually imports:
- hello → https://helloscope-live.iunknown.workers.dev/rooms/hello/#v=1&view=ribbons&scale=map&select=foreign%20libc.so.6
- true → https://helloscope-live.iunknown.workers.dev/rooms/true/#v=1&view=ribbons&scale=map&select=foreign%20libc.so.6
What you see: hello imports 3 libc functions
(exhibit-001-elf-atlas/fixtures/imports.libc.json): puts,
__libc_start_main, and __cxa_finalize. Two of those are pure runtime
plumbing (process start-up and at-exit teardown) — the only call hello makes
to do its actual job is puts.
true imports 46 (…/rooms/true/imports.libc.json). Skim them and the
size story explains itself. The tell-tale groups:
- Internationalization —
setlocale,bindtextdomain,textdomain,dcgettext,nl_langinfo,mbrtoc32,iswprint.truespeaks whatever language your locale asks for;hellospeaks C. - Self-description output —
__printf_chk,__fprintf_chk,__vfprintf_chk,fputs_unlocked,fwrite_unlocked. This is the machinery behind--helpand--version. - Write-error detection at exit —
__fpending,fflush_unlocked,fclose. A correct Unix tool checks that its output actually flushed before it claims success; that's the coreutilsclose_stdoutidiom. - Hardening —
__stack_chk_fail,__memcpy_chk(stack canaries, fortified copies).
The nice twist: click through to any function body — say puts — and note
the provenance banner. Both rooms resolve to the same library: glibc
2.43+r22+g8362e8ce10b2-2, build-id 020d6f7c33b2413f4fe10814c4729dce1387f049.
So the difference isn't a different libc. It's how much of that one libc each
program reaches into.
Step 5 — How they bind (optional, for the curious)
The two even resolve their imports differently. Open the binding machinery:
- hello → https://helloscope-live.iunknown.workers.dev/rooms/hello/#v=1&view=ribbons&scale=map&select=.plt&tab=disasm
- true → https://helloscope-live.iunknown.workers.dev/rooms/true/#v=1&view=ribbons&scale=map&select=.relr.dyn
What you see: hello has a .plt (a 6-row stub table with a puts@plt
entry) plus .rela.plt and .got.plt — classic lazy binding, where puts's
real address is patched in on first call. Its DT_FLAGS_1 is 0x8000000
(DF_1_PIE only).
true has no .plt at all — look at its section list, there's no .plt,
no .rela.plt, no .got.plt. Instead its .dynamic carries DT_FLAGS = 8
(DF_BIND_NOW) and DT_FLAGS_1 = 0x8000001 (DF_1_PIE | DF_1_NOW): every
symbol is resolved eagerly at load time, and its relocations are packed into
the compact .relr.dyn table you've just selected.
What it means: true is built with the full-RELRO / BIND-NOW hardening a
distributed system binary gets; hello is built plain. A structural difference,
not a size one — but it's why the two even look different in the relocation
sections.
The payoff
Line the two .text bars up at true scale (Step 1) one more time. The entire
argument fits in that picture: true is bigger because being a correct,
user-facing Unix tool is not free. It costs a .text full of option parsing,
an i18n subsystem, --help/--version prose, write-error checking at exit, and
compiler hardening — 44 extra libc calls' worth of behavior that a
printf("hello, world") demo never needs. hello's size, meanwhile, is
dominated by fixtures for teaching (debug sections, tables, padding), not by
its 275 bytes of code.
And yet, at the end of the day, both programs just return 0.
Verify it yourself
Every number above is literally in the fixture JSON — you don't have to take the docent's word for it.
true's fixture is served live:
curl -s -H 'User-Agent: helloscope-doc' \
https://helloscope-live.iunknown.workers.dev/rooms/true/true.json > true.json
Then: .file.size → 43248; .sections | length → 28; the .text entry's
.size → 19027; .disassembly[".text"] | length → 4637;
.strings[".rodata"] | length → 46. (jq reads all of these.)
hello's fixture is committed in the repo atexhibit-001-elf-atlas/fixtures/hello.json:.file.size→ 16984;.sections | length→ 37; the.textentry's.size→ 275;.disassembly[".text"] | length→ 68.
- The import lists are the
imports.libc.jsonbeside each fixture:…/rooms/true/imports.libc.json(46 functions) andexhibit-001-elf-atlas/fixtures/imports.libc.json(3 functions). Both are keyed on the same libc build-id,020d6f7c….
If a caption ever disagrees with the JSON, believe the JSON.