DEV Community

Cover image for How Linux Executes a Binary: I Finally Understood It at 33 Years In
Juan Torchia
Juan Torchia

Posted on • Originally published at juanchi.dev

How Linux Executes a Binary: I Finally Understood It at 33 Years In

There are exactly 127 syscalls that an empty Node.js process makes before executing a single line of your code. One hundred and twenty-seven. When I measured it with strace last week, I had to read the output twice, then close the terminal and go for a walk.

I have 33 years of history with computers. I started on an Amiga at age 5, went through DOS, was running Linux servers at 18, and today I'm deploying on Railway with Next.js. And in all that time I never truly understood — in real detail — what happens between typing ./my-program and the program actually running. I dodged it. There was always something more urgent. A deploy. A production bug. A client.

This week I forced myself to go down to the metal. Here's what I found.

Linux ELF Dynamic Linking: How It Actually Works

Let's start at the beginning. When you execute a binary on Linux, the kernel doesn't simply "start" your program. There's a chain of events that most product devs never see:

# Let's see what type of file a binary actually is
file /usr/bin/node
# ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
# dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
Enter fullscreen mode Exit fullscreen mode

There it is. dynamically linked. interpreter /lib64/ld-linux-x86-64.so.2. That's the dynamic linker, and it's the main character of this story.

The ELF Format: The Envelope That Wraps Everything

ELF stands for Executable and Linkable Format. It's basically a file format — like a ZIP but for executable code. Every Linux binary is an ELF file, and it has a very specific structure:

# readelf shows you the guts of an ELF
readelf -h /usr/bin/ls

# ELF Header:
#   Magic:   7f 45 4c 46 02 01 01 00 ...  <- "\x7fELF" — the format signature
#   Class:                             ELF64
#   Entry point address:               0x67d0  <- this is where YOUR code starts
#   Start of program headers:          64 (bytes into file)
#   Number of program headers:         13
Enter fullscreen mode Exit fullscreen mode

The Entry point is the memory address where execution will begin. But — and this is what blew my mind — that code is not the first thing that runs.

The Dynamic Linker: The Middleman You Never Saw

When the kernel sees that an ELF is "dynamically linked", it doesn't execute the entry point directly. It first executes the interpreter — which in practice is /lib64/ld-linux-x86-64.so.2, the dynamic linker.

This process does, in order:

# Let's see what libraries a binary needs
ldd /usr/bin/node

# linux-vdso.so.1 (0x00007ffd8c9f3000)      <- virtual, lives in the kernel
# libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2
# libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6
# libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# /lib64/ld-linux-x86-64.so.2 (0x00007f3a...)  <- the dynamic linker itself
Enter fullscreen mode Exit fullscreen mode
  1. Load the ELF into memory — maps the file's segments
  2. Resolve dependencies — finds each .so the binary needs
  3. Perform relocation — patches memory addresses so everything fits together
  4. Run constructors — initialization code that runs before main()
  5. Hand control over to the real entry point

All of that before your main() runs a single line.

Going Deeper: What Happens With strace

The tool that opened my eyes was strace. It intercepts every syscall a process makes:

# Let's count syscalls in a minimal C program
cat > hello.c << 'EOF'
#include <stdio.h>
int main() {
    printf("hello\n");
    return 0;
}
EOF

gcc -o hello hello.c
strace -c ./hello

# % time     seconds  usecs/call     calls    syscall
# 27.45    0.000156          31         5    mmap       <- map memory
# 18.23    0.000104          20         5    mprotect   <- protect memory regions
# 14.67    0.000083          83         1    munmap
#  9.44    0.000054          27         2    openat     <- open .so files
#  8.92    0.000051          25         2    read
# ...
# Total calls before main(): ~25
Enter fullscreen mode Exit fullscreen mode

Twenty-five syscalls for "hello world". For Node.js it's 127. That makes sense once you understand that Node links against a ton of shared libraries — V8, libuv, OpenSSL.

The Section Header: The Binary's Table of Contents

# Let's look at an ELF's sections
readelf -S /usr/bin/ls | head -30

# [Nr] Name              Type             Address
# [ 0]                   NULL
# [ 1] .interp           PROGBITS         <- path to the dynamic linker
# [ 2] .note.gnu.build-i NOTE
# [ 3] .gnu.hash         GNU_HASH         <- hash table for symbol lookup
# [ 4] .dynsym           DYNSYM           <- dynamic symbol table
# [ 5] .dynstr           STRSYM           <- strings with function names
# [12] .plt              PROGBITS         <- Procedure Linkage Table
# [13] .text             PROGBITS         <- YOUR CODE is here
# [24] .got              PROGBITS         <- Global Offset Table
# [25] .got.plt          PROGBITS         <- GOT for PLT
# [26] .data             PROGBITS         <- initialized global variables
# [27] .bss              NOBITS           <- uninitialized global variables
Enter fullscreen mode Exit fullscreen mode

PLT and GOT: The Magic Trick Behind Lazy Binding

Here's the most elegant part of the whole system. When your program calls printf(), it has no idea at compile time what memory address that function will be at. The library can be anywhere.

The solution is two structures:

  • PLT (Procedure Linkage Table): intermediate code that jumps through the GOT
  • GOT (Global Offset Table): a table of pointers to the real addresses
# First call to printf — lazy binding in action
# 1. Jump to printf@PLT
# 2. PLT reads the GOT — still points to the dynamic linker
# 3. Dynamic linker resolves the real address of printf
# 4. Updates the GOT with the real address
# 5. Executes printf

# Second call to printf — already resolved
# 1. Jump to printf@PLT
# 2. PLT reads the GOT — now points directly to printf
# 3. Executes printf (no dynamic linker involved)

# You can watch this happen with:
LD_DEBUG=bindings ./hello 2>&1 | head -20
# binding file ./hello [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: 
# normal symbol `printf' [GLIBC_2.2.5]
Enter fullscreen mode Exit fullscreen mode

That's lazy binding — the dynamic linker only resolves a function the first time you call it. Elegant and efficient.

The Errors That Taught Me This the Hard Way

Error 1: "No such file or directory" on a Binary That Exists

This happened to me years ago and I "fixed" it without understanding it:

./my-binary
# bash: ./my-binary: No such file or directory

# But the file exists:
ls -la my-binary
# -rwxr-xr-x 1 juan juan 45231 Feb 20 14:32 my-binary
Enter fullscreen mode Exit fullscreen mode

The error isn't that the binary doesn't exist. It's that the interpreter doesn't exist. The dynamic linker specified in the ELF isn't on the system. This happened when I was copying binaries between distros with different layouts.

# Diagnosis:
readelf -l my-binary | grep interpreter
# [Requesting program interpreter: /lib/ld-musl-x86_64.so.1]
# ^ Compiled against musl libc, not glibc. Different distro.
Enter fullscreen mode Exit fullscreen mode

Error 2: Library Version Mismatch in Production

./my-app
# ./my-app: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.33' not found
Enter fullscreen mode Exit fullscreen mode

I compiled on Ubuntu 22.04, deployed on Debian 10. Different glibc version. The real fix is to build in the same environment as production — which is basically why Docker exists.

# Dockerfile that avoids this problem
FROM node:20-alpine AS builder
# Alpine uses musl, not glibc — watch out with native binaries

FROM node:20-slim AS runner
# Debian slim, same glibc as most production environments
Enter fullscreen mode Exit fullscreen mode

This connects directly to what I learned while optimizing performance in production — the build environment matters as much as the code itself.

Error 3: LD_PRELOAD for Good and Evil

# LD_PRELOAD lets you inject a library BEFORE any other, including libc
# Use it carefully — it's powerful and dangerous

# Legitimate example: use tcmalloc instead of the default allocator
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc.so.4 ./my-app

# Debugging example: intercept function calls
# (basically how some agent sandboxes work)
# Related to what I explored in /blog/sandboxes-coding-agents-freestyle
Enter fullscreen mode Exit fullscreen mode

The Freestyle sandbox I analyzed a few days ago uses similar mechanisms — intercepting syscalls at the process level to isolate what an agent can do.

FAQ: Linux ELF and Dynamic Linking

What is an ELF file in Linux?
ELF (Executable and Linkable Format) is the standard format for executable binaries, shared libraries, and object files on Linux. It's basically a structured container that tells the kernel how to load and execute the code. Every modern Linux binary is an ELF — you can verify it with file /path/to/binary.

What's the difference between static linking and dynamic linking?
With static linking, all the libraries your program needs are copied into the binary at compile time. The result is a larger but completely self-contained binary. With dynamic linking, the binary only stores references to libraries, and the dynamic linker loads them at runtime. Dynamic linking is the default because it saves memory (multiple apps share the same libc code in RAM) and makes security updates easier.

Why does a binary sometimes say "No such file or directory" even though it exists?
It usually means the interpreter (dynamic linker) specified in the ELF doesn't exist on that system. You move a binary from Alpine (which uses musl libc) to Ubuntu (which uses glibc) and the path to the dynamic linker just isn't there. You can diagnose it with readelf -l your-binary | grep interpreter.

What is LD_PRELOAD and why is it dangerous?
LD_PRELOAD is an environment variable that tells the dynamic linker to load a specific library BEFORE anything else, including libc. This lets you intercept and replace system functions. It's useful for profiling and debugging, but dangerous because it can be used to inject malicious code. That's why setuid binaries ignore it.

What is the vDSO (linux-vdso.so.1)?
It's a virtual library that the kernel automatically maps into every process's memory space. It contains implementations of very frequent syscalls (like gettimeofday) that execute in user space without a real context switch to the kernel. That's why ldd shows it without a path — it's not a file on disk, it lives in the kernel.

How does this affect Docker and containers?
A lot. Containers share the host kernel but have their own filesystem. If you build a binary in an image with glibc 2.35 and run it in a container with glibc 2.17, it will fail. That's why Docker images need to be consistent between build and runtime. It's also why Alpine-based images (musl libc) can have unexpected behavior with binaries compiled for glibc.

What I'm Taking Away: The Product Dev Who Finally Went Down to the Metal

Honestly, I'm a little embarrassed I dodged this for so long. I've worked with Linux since I was 18, administered servers, diagnosed network outages at 11pm with a room full of people waiting on me, and I never seriously asked what happens in those microseconds between ./program and the first line of code.

The pivot I made in 2020 toward software development pushed me up the abstraction ladder — React, TypeScript, Next.js. Learning to think in components was hard when you'd spent years thinking in network packets. But going up doesn't mean the layers below disappear. They're still there.

When I'm working on LLM inference at the edge or thinking about how to isolate code agents, understanding what happens at the process level matters. Abstractions are useful right up until they break — and when they break, you either go down to the metal yourself or you pay someone who does.

My concrete recommendation: spend an afternoon with strace, ldd, and readelf. Not to become a systems programmer — just to understand the machine that runs your code every single day.

# Start here. Five minutes, on any Linux box:
strace -c ls /tmp 2>&1  # How many syscalls does ls make?
ldd $(which node)        # What does Node depend on?
readelf -h $(which ls)   # What's inside a binary?
file /bin/*              # What types of ELFs live on your system?
Enter fullscreen mode Exit fullscreen mode

The Amiga in 1994 had no dynamic linking — everything was static, everything was in ROM or on disk, and the system was what it was. In a way, that simplicity was more honest. Today we run on layers upon layers upon layers, and every now and then it's worth going down to see what it's all standing on.


How many syscalls does your app make before running a single line of code? Measure it with strace -c ./your-binary and send me the number. I bet it surprises you.

Top comments (0)