Skip to content

ELF and Program Execution

Time: 45 min | Prerequisites: SSH Login, Exploring Linux | Theory companion: Linux Fundamentals, Sections 1.1–1.2


Learning Objectives

By the end of this tutorial you will be able to:

  • Compile a C program on the Raspberry Pi and identify its file type
  • Read ELF headers and sections with readelf and objdump
  • Explain the difference between static and dynamic linking
  • Trace program loading with strace
  • Cross-compile an ARM binary and run it on x86 with QEMU
  • Diagnose common "Exec format error" and missing-library failures
ELF: How Linux Runs Your Program

Every executable on a Linux system is an ELF (Executable and Linkable Format) file. When you type ./my_program, the kernel reads the ELF header to determine the target architecture, entry point, and which shared libraries are needed. The dynamic linker (ld-linux) then maps the program and its libraries into memory before jumping to _start (which eventually calls main).

ELF files are divided into sections (.text for code, .data for initialized globals, .rodata for constants, .bss for zero-initialized data) that the loader maps into memory segments with appropriate permissions (read, write, execute). This section-to-segment mapping is what the MMU enforces at runtime.

Dynamic linking means most programs share a single copy of libc.so in RAM, saving memory on resource-constrained embedded systems. Static linking bakes all library code into the binary -- larger but self-contained, which is why BusyBox and rescue tools use it.

Understanding ELF internals helps you diagnose "Exec format error" (architecture mismatch), missing library failures, and unexpected binary sizes -- all common issues when cross-compiling for embedded targets.

For the full conceptual background on processes, ELF, and dynamic linking, see Linux Fundamentals, Sections 1.1--1.2.


The Example Program

Throughout this tutorial (and the Debugging Practices tutorial), we use a small C program that reads the CPU temperature from sysfs — a meaningful embedded task that uses libc functions (fopen, fgets, printf) so linking behavior is visible.

Create the file on your Pi:

// sensor_reader.c — Read CPU temperature from sysfs
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    FILE *fp = fopen("/sys/class/thermal/thermal_zone0/temp", "r");
    if (fp == NULL) {
        perror("Failed to open thermal sensor");
        return 1;
    }

    char buf[16];
    if (fgets(buf, sizeof(buf), fp) == NULL) {
        perror("Failed to read temperature");
        fclose(fp);
        return 1;
    }
    fclose(fp);

    int raw = atoi(buf);
    printf("CPU temperature: %d.%d °C\n", raw / 1000, (raw % 1000) / 100);
    return 0;
}

1. Compile and Run

# Compile
gcc -o sensor_reader sensor_reader.c

# Run
./sensor_reader
# Expected: CPU temperature: 45.2 °C (or similar)

# What kind of file did we create?
file sensor_reader

The file command tells you:

  • ELF — it is an Executable and Linkable Format binary
  • 32-bit / 64-bit — matches your Pi's architecture (ARM aarch64 on Pi 4 with 64-bit OS, or ARM 32-bit)
  • dynamically linked — it depends on shared libraries at runtime
  • interpreter — the dynamic linker path (e.g., /lib/ld-linux-armhf.so.3)
Checkpoint 1

file sensor_reader shows an ELF executable for ARM, dynamically linked. The program prints the CPU temperature.


2. ELF Structure

2.1 ELF Header

readelf -h sensor_reader

Find these fields in the output:

Field What It Tells You
Magic 7f 45 4c 46 — the four bytes that identify every ELF file
Class ELF32 or ELF64 — word size
Machine ARM (or AArch64) — target architecture
Entry point address Where execution begins (_start, not main)
Type EXEC (fixed address) or DYN (position-independent)

2.2 Sections

readelf -S sensor_reader

Fill in this table from the output:

Section Size Purpose
.text Machine code
.data Initialized globals
.bss Zero-initialized globals
.rodata String constants, read-only data
.dynamic Dynamic linking info

2.3 Find Your Strings

# Show the contents of .rodata — your format strings live here
objdump -s -j .rodata sensor_reader

You should see "CPU temperature: %d.%d °C\n" and the sysfs path in the output.

2.4 See the Assembly

# Disassemble the main function
objdump -d sensor_reader | grep -A 40 '<main>'

Even without knowing ARM assembly, you can spot:

  • bl instructions — function calls (to fopen, printf, etc.)
  • Labels like <fopen@plt> — calls through the Procedure Linkage Table (dynamic linking)
Checkpoint 2

You can read ELF headers, identify sections and their sizes, find string constants in .rodata, and see function calls in the disassembly.


3. Dynamic Linking

3.1 List Shared Libraries

# What libraries does our program need at runtime?
ldd sensor_reader

Typical output:

libc.so.6 => /lib/arm-linux-gnueabihf/libc.so.6
/lib/ld-linux-armhf.so.3

Our program uses fopen, printf, atoi — all from libc. The dynamic linker (ld-linux) loads libc at runtime.

# The ELF file records which libraries it needs
readelf -d sensor_reader | grep NEEDED

3.2 Runtime Memory Layout

# Start the program in the background and inspect its memory map
./sensor_reader &
READER_PID=$!
cat /proc/$READER_PID/maps
kill $READER_PID
Tip

The memory map shows where the ELF segments, shared libraries, stack, and heap are loaded. Each has different permissions (r/w/x). This is the MMU in action — Section 9 of Theory 2.

3.3 Static vs Dynamic

# Compile a statically linked version — all library code is embedded
gcc -static -o sensor_reader_static sensor_reader.c

# Compare sizes
ls -lh sensor_reader sensor_reader_static

# Compare library dependencies
ldd sensor_reader
ldd sensor_reader_static
# "not a dynamic executable"

# Both produce the same output
./sensor_reader_static
Dynamic Static
Binary size ~16 KB ~600+ KB
Shared libraries needed Yes (libc) None
Startup time Slightly slower (linker runs) Faster (no linking step)
Disk usage with many programs Efficient (libc shared) Wasteful (each binary has its own copy)
Use case Normal Linux systems Buildroot/BusyBox, rescue tools
Checkpoint 3

You can list shared library dependencies with ldd, inspect runtime memory maps, and compare static vs dynamic linking.


4. Tracing Program Loading

strace intercepts system calls — the interface between your program and the kernel.

# Trace the dynamic version — watch the kernel load libraries
strace -e trace=execve,openat,mmap ./sensor_reader 2>&1 | head -40

You will see:

  1. execve("./sensor_reader", ...) — the kernel starts loading the ELF
  2. Multiple openat calls — opening shared libraries (libc.so.6)
  3. Multiple mmap calls — mapping library code and data into memory
  4. Eventually, your program runs and reads the temperature
# Now trace the static version
strace -e trace=execve,openat,mmap ./sensor_reader_static 2>&1 | head -20

The static version has far fewer system calls — no library loading.

# Count total system calls for each version
strace -c ./sensor_reader 2>&1 | tail -5
strace -c ./sensor_reader_static 2>&1 | tail -5
Checkpoint 4

You can trace program loading with strace and explain why the dynamic version makes more system calls than the static version.


5. QEMU: Run ARM Binaries on Your Laptop

Note

This section runs on your host laptop (x86/amd64), not on the Pi. You need gcc-arm-linux-gnueabihf and qemu-user installed.

# Ubuntu/Debian:
sudo apt install gcc-arm-linux-gnueabihf qemu-user qemu-user-static

5.1 Cross-Compile

# Compile for ARM on your x86 laptop
arm-linux-gnueabihf-gcc -static -o sensor_reader_arm sensor_reader.c
file sensor_reader_arm
# ELF 32-bit LSB executable, ARM, ...

5.2 Run with QEMU User-Mode

# Run the ARM binary on your x86 machine
qemu-arm ./sensor_reader_arm
Warning

The sysfs path /sys/class/thermal/thermal_zone0/temp may not exist on your laptop (different hardware). The program will print an error — that is expected and demonstrates that QEMU emulates the CPU, not the hardware.

5.3 Debug with QEMU + GDB

# Terminal 1: Start under QEMU, paused, waiting for GDB on port 1234
qemu-arm -g 1234 ./sensor_reader_arm

# Terminal 2: Attach GDB
gdb-multiarch ./sensor_reader_arm
(gdb) target remote :1234
(gdb) break main
(gdb) continue
(gdb) next
(gdb) print buf
(gdb) quit

This gives you full source-level debugging of ARM code on your x86 laptop — no Pi needed.

Checkpoint 5

You can cross-compile for ARM, run the binary on x86 with QEMU, and attach GDB to debug it.


6. Diagnosing Common Problems

6.1 Exec Format Error

# Try running an ARM binary directly on x86 (without QEMU)
# This simulates what happens when you copy a binary to the wrong platform
file sensor_reader_arm
# ELF 32-bit LSB executable, ARM, ...

# If run on x86 without binfmt/QEMU:
# bash: ./sensor_reader_arm: cannot execute binary file: Exec format error

Cause: The kernel checks the ELF header and finds an architecture mismatch. Fix: Use QEMU, or compile for the correct target.

6.2 Missing Shared Library

# Simulate a missing library path
LD_LIBRARY_PATH=/nonexistent ./sensor_reader
# error while loading shared libraries: libc.so.6: cannot open shared object file

Cause: The dynamic linker cannot find a required .so file. Fix: Install the library, fix LD_LIBRARY_PATH, or use ldd to identify what is missing.

6.3 Wrong Interpreter

# Check the interpreter (dynamic linker) path
readelf -l sensor_reader | grep interpreter
# [Requesting program interpreter: /lib/ld-linux-armhf.so.3]

If this path does not exist on the target system (e.g., different libc version), the binary will fail to start. This is common when copying binaries between different distributions.

Checkpoint 6

You can explain and diagnose "Exec format error", missing library, and wrong interpreter failures.


Summary

Tool What It Shows Command
file File type, architecture, linking file ./binary
readelf -h ELF header (arch, entry point) readelf -h ./binary
readelf -S Section table (.text, .data, .bss) readelf -S ./binary
readelf -d Dynamic linking info readelf -d ./binary
ldd Shared library dependencies ldd ./binary
objdump -d Disassembly objdump -d ./binary
strace System call trace strace ./binary
/proc/PID/maps Runtime memory layout cat /proc/$$/maps
qemu-arm Run ARM binary on x86 qemu-arm ./arm_binary

Challenge

Write a script elf_report.sh that takes a binary as an argument and prints:

  1. File type and architecture (file)
  2. Entry point address (readelf -h)
  3. Number of sections (readelf -S | grep -c '^\s*\[')
  4. Whether it is statically or dynamically linked (ldd)
  5. List of shared libraries (if dynamic)
  6. Size of .text section (readelf -S)
chmod +x elf_report.sh
./elf_report.sh /usr/bin/ls
./elf_report.sh sensor_reader
./elf_report.sh sensor_reader_static

Deliverable

Upload to your lab journal:

  • Filled-in ELF sections table from Section 2.2
  • Output of ldd for both dynamic and static versions
  • strace -c comparison (system call counts)
  • elf_report.sh script

Next Steps


Course Overview