Skip to content
Go back

Shrinking Binaries - How small can a binary get before it breaks?

This idea struck me while watching Sheafification of G’s video, C++ is the BEST interpreted language. It got me thinking about how programming languages model memory and, in turn, led me to Bjarne Stroustrup’s classic paper, Abstraction and the C++ Machine Model. Stroustrup shows how C++ balances high-level abstraction with machine-level control — a perspective that inspired me to strip a binary down to its bare bones.

With that in mind, let’s dive into the real experiment: how small can we make a binary and still have it execute?

NOTE: Notice the subtitle for each stage in this experiment? That’s the binary’s size in bytes — a running scoreboard of our progress in the shrinking game.

Table of contents

Open Table of contents

What exactly is a binary executable?

It’s wild to think that the same machine can run everything from low-level device drivers to high-level frameworks like React. As different as they seem, they all collapse down to the same thing: ones and zeros executed by the CPU and GPU.

That raises today’s central question. How small can we make a binary executable that a CPU can process without running into issues?

I’m running on the Linux Distro Ubuntu 24.04.3 LTS (Noble Numbat) with an x86_64 CPU architecture. On Linux, programs live inside a format called ELF (Executable and Linkable Format) — think of it as a container that tells the OS where everything should go in memory and how to start the program.

Rather than jumping straight into clever assembly tricks, we’ll start at the top: the tiniest C program we can compile with the GCC compiler. From there, we’ll peel back the layers step by step — first rewriting it in NASM then experimenting with Linker Scripts - each stage shaving off more overhead while keeping the binary runnable.

So before we even write any clever assembly, let’s start at the high-level and see what GCC gives us out of the box.

What actually happens when you run a program?

When you double-click a program or tell the OS to execute the binary from the terminal, with a command like ./<name-of-binary>, a little chain of events kicks off. First, the OS looks inside the ELF executable to see which parts should go into memory and where. If the program needs extra pieces (shared libraries), the system plugs them in before starting. Finally, the CPU steps in — reading the ones and zeros as instructions, figuring out what they mean, and carrying them out one by one at rapid speed. My laptop contains an Intel Core i7 processor with 8 cores.

blah

Running lscpu on my system shows that the actual speed can range from 0.4GHz up to 4.8GHz on each core. So pretty quick in terms of human perception!

It’s time to see how small we can make a binary executable.

(15784) Starting small with GCC

Let’s start with the simplest C program we can write.

void main(void) {}

This is as simple as it gets. Of course this program does nothing useful, but it’s still a valid one.

We can compile this program into a binary executable with the following.

gcc -o smallest_gcc smallest_gcc.c

Running

stat -c %s smallest_gcc

on the compiled file tells us that its exactly 15,784 bytes. So about 15.8K.

15.8K might sound tiny in today’s world of gigabyte apps, but for a program that does absolutely nothing, it’s actually quite bloated. Where does all that overhead come from? Let’s peek inside.

N.B. I wanted a way to visualise the binary graphically without getting swamped in the nitty gritty, so I’ve written a noddy Python script to render a visual representation of the binary’s contents in a sort of 1-dimensional heatmap.

heatmap-gcc-out-of-the-box

Each block in the heatmap corresponds to a section in the ELF file — showing how much space headers, metadata, and other scaffolding take up compared to the actual code.

The darker the colour, the bigger that section is relative to the other sections.

There are quite a few sections making up this ELF binary. We won’t labour on what each and every one is there for. Instead, we’ll focus on determining what we can safely strip out.

There are three notable sections of the file that seem to occupy the most space in the file, when compiled with GCC out of the box.

ELF SectionRole
.textHouses the actual executable machine code (instructions) of the program. It’s typically read-only and executable to prevent accidental modification.
.dynamicHolds metadata for the dynamic linker/loader (like needed shared libraries, symbol hashes, relocation info, etc.) so the binary can be properly linked at runtime.
.shstrtabContains the names of all the sections in the ELF file. Only needed for the linker, debugger, or inspection tools. The program loader in the OS doesn’t use it at runtime.

Here’s the ELF header that GCC produced. Even our do-nothing program comes wrapped in this metadata, which sets up how the OS will load it.

debugpin@debugpin:~/gh/inC/smallest_gcc$ readelf -h ./smallest_gcc
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x1040
  Start of program headers:          64 (bytes into file)
  Start of section headers:          13928 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         13
  Size of section headers:           64 (bytes)
  Number of section headers:         29
  Section header string table index: 28

In order to know what we can strip out, in an ideal world we’d go through every single section in this out-of-the-box binary and then remove them one at a time. However, to maintain a level of brevity, I’m going to summarise what we absolutely need in order for a binary to successfully run.

At the absolute minimum, an executable needs a .text section for instructions and an entry point to start execution. Global variables go into .data or .bss if needed, and read-only constants go into .rodata. Everything else - dynamic linking info, notes, EH frames, stack protections - is optional for a tiny standalone program, like our one.

In our case, we’re not even defining or declaring any variables or constants. Therefore, we’ll get away with eliminating .data, .bss and .rodata.

(8816) Compiling without the C Standard Library

Next, we replace our minimal C program with a version that uses inline Assembly. Functionally, it does the same thing - nothing - but now we bypass glibc entirely by calling _start directly instead of going through __libc_start_main. This removes the dependency on glibc, along with all the dynamic linking that comes with the C runtime and standard library.

void _start() {
    __asm__("mov $60, %rax\n"  // syscall: exit
            "xor %rdi, %rdi\n" // exit code 0
            "syscall");
}

We can compile this with additional flags:

GCC flagWhat it tells the compiler
-nostdlibSkip linking the standard C library (libc) and standard startup files (crt0, etc.).
-staticProduce a statically linked executable, so all library code is included in the binary and no dynamic linking occurs at runtime.
-sStrip the binary of symbol tables and debug information to reduce size.
gcc -nostdlib -static -s smaller.c -o smaller

Running

stat -c %s smaller

shows it is now exactly 8,816 bytes - about 55.85% the size of the first binary. In other words, roughly 44% of the original binary came from glibc and related runtime sections that are now absent.

heatmap-no-stdlib

Notably, the .text section, which contains the executable instructions, is now less than one-tenth the size of the original binary - a massive reduction. The shared string table is just over 20% of the original size, reflecting that many strings in the first binary came from glibc. By using the -static flag, we also remove dynamic linking sections entirely, saving additional space.

(4648) Going even smaller with NASM

Now that we’ve removed glibc, we can take things further by writing our binary directly in Assembly using NASM. This gives us complete control over every instruction and eliminates any remaining overhead from the compiler runtime.

Here’s our simple program that just exits cleanly.

; tiny.s
section .text
global _start
_start:
    mov rax, 60     ; syscall: exit
    xor rdi, rdi    ; status = 0
    syscall

Compiling this “out-of-the-box” with NASM and linking it produces a much small binary that you can get out of GCC.

nasm -f elf64 tiny.s && ld -o tiny tiny.o

This produces a binary that is 4648 bytes in size, which is about 29% of the first GCC binary out of the box.

heatmap-nasm-out-of-the-box

The 68 bytes of GNU specific headers are no longer present and the 56 byte eh_frame section which is responsible for holding rules regarding the initial stack frame and standard call convections (what CPU registers are used for).

In our NASM program we are controlling registers directly and speaking directly with the Linux ABI to trigger the exit syscall.

Notice the new .symtab section though. This is baked in by the GNU Linker. It’s where labels, functions, and variables are listed, along with their addresses, sizes, and binding. Unless explicitly stripped (ld -s or strip), GNU ld will keep the .symtab section in the final executable. We’ll save stripping symbols and padding to the very end.

(584) Super small: Linking by Hand

At this point, we’ve got a minimal NASM program, but we can further strip down the ELF binary by controlling what sections are included. We do this by explicitly writing a Linker Script that we’ll pass to the GNU Linker.

Our linker script looks as follows.

/**
 * Creates a PT_LOAD for .text and places code
 * just after the ELF headers so load addresses line up.
 * Written for x86-64.
 */

OUTPUT_FORMAT("elf64-x86-64")
ENTRY(_start)

PHDRS {
  text PT_LOAD FLAGS(5); /* PF_R | PF_X */
}

SECTIONS
{
  /* Put the section right after ELF + program headers */
  . = 0x400000 + SIZEOF_HEADERS;

  .text : {
    *(.text)
  } :text

  /* discard some unneeded sections */
  /DISCARD/ : { *(.note*) *(.comment) *(.eh_frame*) }
}

Here, we are telling the GNU Linker how to lay out the ELF binary in memory.

heatmap-nasm-with-linker-script

This binary is 584 bytes in size.

debugpin@debugpin:~/gh/inC/beyond_gcc/insy$ readelf -h insy
ELF Header:
  . . . truncated . . .
  Start of section headers:          264 (bytes into file)
  . . . truncated . . .
  Number of program headers:         1
  . . . truncated . . .
  Number of section headers:         5
  Section header string table index: 4

Notice also at this point, that we have only one program header, PT_LOAD. This is all we need for this program to be loaded.

(352) Trimming the Fat: Removing Unnecessary Sections

Linux comes with a nifty tool for stripping extraneous symbols and other object data from ELF binaries suitably called strip.

We’ll use this tool to chip more ones and zeroes off that we can get away with.

strip --strip-all insy

Following a bit of strippage, the binary now is 352 bytes in size. So we’ve shaved off 323 bytes just like that. The .symtab and .strtab tables are gone.

heatmap-nasm-with-linker-script-and-stripped

SectionPurpose
.symtabThe symbol table. Contains all symbols in the binary (functions, global variables, labels) with their addresses, sizes, and types. Mainly used for linking and debugging.
.strtabthe string table. Stores the names of the symbols referenced by .symtab. Instead of repeating names in .symtab, it just stores offsets into .strtab.

(138) Super Strip: The Absolute Minimum

debugpin@debugpin:~/gh/inC/beyond_gcc/insy$ readelf -h insy
ELF Header:
  . . . truncated . . .
  Start of section headers:          0 (bytes into file)
  . . . truncated . . .
  Number of program headers:         1
  . . . truncated . . .
  Number of section headers:         0
  Section header string table index: 0

After stripping .symtab and .strtab, we’ve essentially removed everything the linker and debugger might need, leaving just the bare essentials for the OS to load and execute the program. The binary is now down to 352 bytes, and the ELF headers reflect that we have:

This is about as minimal as a Linux x86-64 binary can get while still being functional. At this point, every single byte counts, and we’re no longer carrying any “baggage” from compilers or standard libraries.

Interestingly, if you peek inside this stripped binary using a hex editor or hexdump, you’ll see just enough to satisfy the kernel:

The final size of the insy binary is 138 bytes. That’s pretty tidgy.

Here it is. The final runnable binary, stripped down to its bare bones.

00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000016: 0200 3e00 0100 0000 8000 4000 0000 0000  ..>.......@.....
00000032: 4000 0000 0000 0000 0000 0000 0000 0000  @...............
00000048: 0000 0000 4000 3800 0100 4000 0000 0000  [email protected]...@.....
00000064: 0100 0000 0500 0000 8000 0000 0000 0000  ................
00000080: 8000 4000 0000 0000 8000 4000 0000 0000  ..@.......@.....
00000096: 0a00 0000 0000 0000 0a00 0000 0000 0000  ................
00000112: 0010 0000 0000 0000 0000 0000 0000 0000  ................
00000128: b83c 0000 0048 31ff 0f05                 .<...H1...

From Kilobytes to Bytes: Our journey from small to insy

StageSize in bytes
(small) GCC out of the box15784
(smaller) GCC without the standard library8816
(tiny) NASM out of the box4648
(insy) NASM with explicit Linker Script584
insy + strip352
insy + strip + sstrip138

elf_size_graph

Reflection: why this matters

This experiment highlights a few fascinating truths about programs.

Binaries are more than just executable bytes

Our do-nothing GCC program was ~15 KB. By cutting out glibc, the compiler runtime, and unneeded ELF metadata, we shrank it by over 97%. It’s a striking reminder that everyday applications carry a lot of invisible scaffolding. Of course, larger programs will comprise of more executable instructions and the more of the ELF metadata will be required.

Understanding the OS and ABI matters

To go this small, you need to know how the operating system loads programs, how the CPU expects entry points, and how system calls work. Compilers and libraries abstract all of that away. He discovered some of this infrastructure today.

Control comes at the cost of convenience

Writing directly in assembly and crafting custom linker scripts is labor-intensive and error-prone - but it gives total control. This is why most developers rely on compilers and libraries: the trade-off is usually worth it.

Size isn’t the only metric

While shrinking binaries is fun and educational, it’s not always practical. Modern applications prioritise maintainability, readability, security, and performance over minimal byte size. But understanding what each layer adds can help you make smarter choices about dependencies, libraries, and linking strategies.

Conclusion

Through a combination of minimal C code, direct assembly, linker scripts, and the strip tool, we’ve explored just how lean a Linux binary can get: from 15.8 KB down to 138 bytes, all while still being executable. Along the way, we peeked inside ELF sections, learned what some of them are for, and gained an appreciation for the invisible machinery that makes even the simplest programs work.

So, how small can a binary get before it breaks? The answer depends on your definition of “breaks.” If the goal is simply to execute and exit cleanly, a few hundred bytes is enough. If the goal is usability, debugging, and maintainability, then you’re looking at orders of magnitude larger.

This experiment was more than just a numbers game - it’s a glimpse into the layered complexity of software and the beauty of the abstractions we often take for granted.


Share this post on: