Mohit Mishra

Executable Files for Linux

Under the Hood of ELFs for Flexible and Secure Low-Level Coding

Mohit Mishra

Bangalore, Karnataka, India

ISBN 979-8-8688-2153-0e-ISBN 979-8-8688-2154-7

https://doi.org/10.1007/979-8-8688-2154-7

© Mohit Mishra 2026

This work is subject to copyright. All rights are solely and exclusively licensed by the Publisher, whether the whole or part of the material is concerned, specifically the rights of translation, reprinting, reuse of illustrations, recitation, broadcasting, reproduction on microfilms or in any other physical way, and transmission or information storage and retrieval, electronic adaptation, computer software, or by similar or dissimilar methodology now known or hereafter developed.

The use of general descriptive names, registered names, trademarks, service marks, etc. in this publication does not imply, even in the absence of a specific statement, that such names are exempt from the relevant protective laws and regulations and therefore free for general use.

The publisher, the authors and the editors are safe to assume that the advice and information in this book are believed to be true and accurate at the date of publication. Neither the publisher nor the authors or the editors give a warranty, expressed or implied, with respect to the material contained herein or for any errors or omissions that may have been made. The publisher remains neutral with regard to jurisdictional claims in published maps and institutional affiliations.

Distributed to the book trade worldwide by Springer Science+Business Media New York, 1 New York Plaza, New York, NY 10004. Phone 1-800-SPRINGER, fax (201) 348-4505, e-mail orders-ny@springer-sbm.​comwww.​springeronline.​com, or visit . Apress Media, LLC is a Delaware LLC and the sole member (owner) is Springer Science + Business Media Finance Inc (SSBM Finance Inc). SSBM Finance Inc is a Delaware corporation.This Apress imprint is published by the registered company APress Media, LLC, part of Springer Nature.

The registered company address is: 1 New York Plaza, New York, NY 10004, U.S.A.

I would like to express my sincere gratitude to my parents for their unwavering support and sacrifices throughout my life.

Introduction

Ever looked at your operating system and thought, “How the heck does this thing actually run under the hood?” Yeah, that’s exactly what got me started on my journey of building an OS. It’s a straight-up guide to rolling your own kernel from the ground up, because nothing tops the rush of booting code you’ve written yourself, even if it crashes in a blaze of glory. I’m Mohit Mishra, a total low-level programming nut who’s spent years knee-deep in ELF files, dynamic linking, and binary debugging. This book isn’t some dusty lecture from on high; it’s the raw, messy log of my own screwups and breakthroughs, like battling page faults at 3 a.m. or tweaking interrupts until they finally hum just right. My background is in systems programming. I have built open-source tools for ELF binarybr /analysis, OS concepts, and reverse engineering. Those repositories have attracted over abr /thousand stars and active contributors, which told me something I did not expect: there are farbr /more developers curious about this topic than there are good resources for learning it. That gapbr /is what this book addresses.br /I maintain two technical blogs, The Core Dump and Chessman Profile and my Substack onebr /(Low-Level Lore), where I write about low-level programming and compiler behavior.

If you’re a developer tired of hiding behind high-level abstractions and ready to get your hands greasy with real systems work, maybe for nailing interviews, hacking embedded gear, or just figuring out why your laptop doesn’t fry, this one’s for you. We’ll build it brick by brick: kicking off with a bootloader, then juggling processes, file systems, and drivers, all in straightforward C with tools that won’t gather dust. Get ready for code chunks, handy cheat sheets, and those electric “aha” flashes that make you feel unstoppable. Strap in; by the end, you’ll grok OS guts inside out and might even spin off your own twist. Let’s fire this thing up.

Any source code or other supplementary material referenced by the author in this book is available to readers on GitHub. For more detailed information, please visit https://​www.​apress.​com/​gp/​services/​source-code.

Acknowledgments

Writing this book felt like a crazy adventure, bouncing from kernel basics to tricky context switches, and I wouldn’t have survived it without the people who kept me going, mostly by keeping my coffee cup full. First off, I’m grateful to my own curiosity that dragged me into the world of low-level programming in the first place. A massive thank-you to the open source crowd on GitHub and Discord, you guys who starred my repos, swapped debugging stories, and called out my silly mistakes. You’re the true heroes; this book is pretty much a nod to all the smarts we share. Shoutout to my family for tolerating my all-night coding binges, Mom, those bottomless cups of chai saved Chapter 5 more times than I can count. Big thanks to my beta readers and early testers who waded through rough drafts, buggy code, and endless typos, your notes made this mess into something worth reading. And to the whole reverse engineering gang, thanks for turning solo late-night hacks into a worldwide conversation. If I left anyone out, it’s the all-nighters talking, keep tinkering, keep crashing, and thanks for riding along.

About the Author and About the Technical Reviewer

About the Author

Mohit Mishra

is a low-level programming enthusiast with extensive experience in executable file formats (ELF), linking, loading, and debugging. He has worked on projects involving ELF file analysis, dynamic linking, and performance optimization. Mohit is the creator of the “Reversing Bits Cheatsheets” repository, which provides comprehensive guides on tools essential for assembly programming, reverse engineering, and binary analysis. His technical articles and tutorials on low-level programming have been widely appreciated by the developer community.

About the Technical Reviewer

Seth Kenlon

is a UNIX and Linux geek, open source enthusiast, and miniatures wargamer. He’s an educator and games designer. He codes mostly in Lua and Java.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_1

1. Why Is My “Hello World” So Big?

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

The “Hello, World!” program is a classic first step for anyone learning to code in C. You write a few lines, compile them, and see your message on the screen. It is a simple and satisfying start. But if you inspect the compiled program on a Linux system, you will find a small surprise. The executable file is thousands of bytes large. Why does a program with a single instruction to print text need all that space?

This question opens the door to understanding how modern operating systems prepare and run software. The answer is not just a piece of trivia. Exploring the contents of this small executable provides a practical foundation for building more efficient software, tracking down complex bugs, and grasping how the Linux kernel executes your code.

To figure out what fills those extra kilobytes, we will look directly inside the executable file. We will compile a basic C program and then use standard development tools to dissect it. This process will show you the different components that the compiler and linker add to your original code. You will learn about the sections that store executable instructions, program data, and information for the operating system.

By the end of this chapter, you will understand the structure of an Executable and Linkable Format (ELF) file, the standard for executables on Linux. You will see firsthand why dynamic linking to system libraries, like the C standard library, contributes to the file’s size. Most importantly, you will have the skills to analyze your own programs and see what is really inside them.

Our Starting Point: The Simplest C Program

Let’s begin with the classic “Hello, World!” program:

#include <stdio.h>

int main() {

    printf("Hello, World!\n");

    return 0;

}

Save this as hello.c and compile it with gcc:

$ gcc -o hello hello.c

Now, let’s examine its size:

$ ls -l hello

-rwxr-xr-x 1 user user 16696 Oct 23 10:30 hello

16,696 bytes! That’s shocking when you consider that our source code is merely 67 bytes. Let’s put this in perspective:

Source Code: 67 bytes

Executable: 16,696 bytes

Ratio: The executable is roughly 249 times larger than the source code!

This enormous disparity raises a compelling question: What exactly is filling those additional 16,629 bytes? The answer will take us on a journey through the internals of Linux executables.

The Executable: More Than Meets the Eye

You might be thinking, “Wait, shouldn’t my executable just be a translation of my C code into machine code?” In an ideal, simplified world, perhaps. But in reality, your executable must carry much more than just your code.

When we compile our tiny C program, we’re actually creating a sophisticated container that packages

The machine code translation of our C source

Various types of data needed by that code

Information about how the program should be loaded

Details about how to connect to shared libraries

Infrastructure to initialize the program environment

Support structures for debugging and error handling

All of this is carefully organized into a standardized format called ELF, which we’ll explore in detail next.

Introduction to the ELF Format

ELF (Executable and Linkable Format) is the standard binary format for executables, object files, and shared libraries on Linux. We’ll explore ELF in great detail in Chapter 2, but for now, let’s understand its basic structure.

An ELF file consists of several key components:

ELF Header: The “table of contents” for the file

Program Header Table: Instructions for how to load the program

Various Sections: Containing code, data, and metadata

Section Header Table: A directory of all the sections

Let’s use readelf to peek at the ELF header:

$ readelf --file-header hello

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:               0x1060

  Start of program headers:          64 (bytes into file)

  Start of section headers:          14960 (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:         31

  Section header string table index: 30

This header alone is 64 bytes—almost the same size as our entire source code! Even at this early stage, we can see that the ELF format adds considerable infrastructure around our simple program.

The Anatomy of an Executable: A Deeper Look

Let’s examine the different segments that make up our executable. These segments serve diverse purposes, each contributing to the overall size of the file.

Code and Data Segments: Where Your Program Lives

At the core of any executable are sections that store your actual program:

Code Segment (.text): This section houses your compiled machine instructions—the actual code that will execute. In our example, this includes the instructions for calling printf and returning from main().

Data Segments: These store the variables and constants used by your program:

.data: Initialized global and static variables

.rodata: Read-only data, such as string constants (like our “Hello, World!” message)

.bss: Space reserved for uninitialized global variables (doesn’t actually occupy file space, but is allocated in memory when the program runs)

In a simplistic world, these sections might be all we need. But Linux executables require much more infrastructure.

Essential Infrastructure: Making Your Program Runnable

Modern executables include several sections dedicated to ensuring they can be loaded and executed properly:

Headers: Both the ELF header and program headers contain essential metadata that the operating system needs to know how to load and run your program.

Dynamic Linking Information: Most Linux programs are dynamically linked, meaning they connect to external libraries at runtime rather than embedding all their dependencies. This requires several dedicated sections:

Information about which shared libraries are needed

Tables of symbols (function names) that need to be resolved

Instructions for how to patch the code with correct addresses

Initialization Infrastructure: Before your main() function runs, the C runtime needs to set up the environment. Special sections handle this setup.

Exception Handling Support: Even though our simple C program doesn’t use exceptions, the executable includes infrastructure to support proper error handling and interoperability with C++ code.

Runtime Support: Making Your Program Robust

Modern executables also include structures that support debugging, error reporting, and security features:

Symbol Tables: These map names (like function and variable names) to addresses, essential for debugging.

String Tables: These store all the string constants used by various parts of the executable.

Debug Information: If compiled with debugging enabled, the executable will include information to help debuggers map machine code back to your source code.

Security Mechanisms: Modern executables include various sections that support Address Space Layout Randomization (ASLR) and other security features.

Examining the Sections: A Guided Tour

Let’s use objdump to look at the sections in our executable:

$ objdump --headers hello

hello:     file format elf64-x86-64

Sections:

Idx Name          Size      VMA               LMA               File off  Algn

  0 .interp       0000001c  0000000000000318  0000000000000318  00000318  2**0

  1 .note.gnu.property 00000030  0000000000000338  0000000000000338  00000338  2**3

  2 .note.gnu.build-id 00000024  0000000000000368  0000000000000368  00000368  2**2

  3 .note.ABI-tag 00000020  000000000000038c  000000000000038c  0000038c  2**2

  4 .gnu.hash     00000024  00000000000003b0  00000000000003b0  000003b0  2**3

  5 .dynsym       000000a8  00000000000003d8  00000000000003d8  000003d8  2**3

  6 .dynstr       0000008c  0000000000000480  0000000000000480  00000480  2**0

  7 .gnu.version  0000000e  000000000000050c  000000000000050c  0000050c  2**1

  8 .gnu.version_r 00000020  0000000000000520  0000000000000520  00000520  2**3

  9 .rela.dyn     000000c0  0000000000000540  0000000000000540  00000540  2**3

 10 .rela.plt     00000018  0000000000000600  0000000000000600  00000600  2**3

 11 .init         00000017  0000000000001000  0000000000001000  00001000  2**2

 12 .plt          00000020  0000000000001020  0000000000001020  00001020  2**4

 13 .plt.got      00000008  0000000000001040  0000000000001040  00001040  2**3

 14 .text         00000195  0000000000001050  0000000000001050  00001050  2**4

 15 .fini         0000000d  00000000000011e8  00000000000011e8  000011e8  2**2

 16 .rodata       00000012  0000000000002000  0000000000002000  00002000  2**4

 17 .eh_frame_hdr 00000044  0000000000002014  0000000000002014  00002014  2**2

 18 .eh_frame     00000108  0000000000002058  0000000000002058  00002058  2**3

 19 .init_array   00000008  0000000000003db8  0000000000003db8  00002db8  2**3

 20 .fini_array   00000008  0000000000003dc0  0000000000003dc0  00002dc0  2**3

 21 .dynamic      000001f0  0000000000003dc8  0000000000003dc8  00002dc8  2**3

 22 .got          00000048  0000000000003fb8  0000000000003fb8  00002fb8  2**3

 23 .data         00000010  0000000000004000  0000000000004000  00003000  2**3

 24 .bss          00000008  0000000000004010  0000000000004010  00003010  2**0

That’s a lot of sections! Let’s break down the most important ones and understand why they’re necessary:

Essential Code and Data Sections

.text Section (the Machine Code)

Let’s examine the actual machine code generated for our main function:

$ objdump --disassemble hello | grep -A20 ’<main>:’

0000000000001129 <main>:

    1129:       55                      push   %rbp

    112a:       48 89 e5                mov    %rsp,%rbp

    112d:       48 8d 05 d1 0e 00 00    lea    0xed1(%rip),%rax

    1134:       48 89 c7                mov    %rax,%rdi

    1137:       e8 f4 fe ff ff          call   1030 <puts@plt>

    113c:       b8 00 00 00 00          mov    $0x0,%eax

    1141:       5d                      pop    %rbp

    1142:       c3                      ret

This .text section contains the actual machine instructions that your processor will execute. Notice several fascinating details:

The compiler has optimized our printf(“Hello, World!\n”) call into a call to puts, which is more efficient for simple string printing. You can see this in the line call 1030 <puts@plt>, which calls the puts function through the Procedure Linkage Table (PLT).

The function begins with a “prologue” that sets up the stack frame (notice the push %rbp instruction at the start) and ends with an “epilogue” that restores the stack (the pop %rbp instruction before ret). This infrastructure is needed to support proper function calling conventions.

The actual machine code for our simple one-line function is 26 bytes, considerably larger than you might expect.

.rodata Section (Read-Only Data)

Where does our “Hello, World!” string live? Let’s find out:

$ objdump --full-contents --section=.rodata hello

Contents of section .rodata:

 2000 01000200 48656c6c 6f2c2057 6f726c64 ....Hello, World

 2010 2100                                  !.

Our string constant lives in the .rodata (read-only data) section. It’s stored as a null-terminated string, with proper alignment. The entire section in our example is 18 bytes.

Dynamic Linking Infrastructure

Our executable needs several sections to support dynamic linking to the C standard library:

.interp Section

$ readelf --string-dump=.interp hello

String dump of section ’.interp’:

  [     0]  /lib64/ld-linux-x86-64.so.2

This tiny section tells the kernel which dynamic linker should load our program. The dynamic linker is a special program that prepares our executable for running by connecting it to shared libraries.

Dynamic Symbol Sections (.dynsym, .dynstr)

$ readelf --symbols hello | grep FUNC

     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.2.5 (2)

    12: 0000000000001060    35 FUNC    GLOBAL DEFAULT   14 _start

    14: 0000000000001129    26 FUNC    GLOBAL DEFAULT   14 main

    [... additional symbols omitted ...]

These sections contain information about functions our program uses from shared libraries (like puts from the C library). They help the dynamic linker connect our program to the actual implementation of these functions.

Runtime Support Sections

Initialization and Finalization Sections

$ readelf --dynamic hello | grep INIT

 0x000000000000000c (INIT)               0x1000

 0x0000000000000019 (INIT_ARRAY)         0x3db8

 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)

These sections (.init, .init_array, .fini, .fini_array) contain code that runs before and after our main() function. They handle program initialization and cleanup tasks, such as setting up the C runtime environment.

Exception Handling Support

$ readelf --sections hello | grep -A2 ".eh_frame"

  [17] .eh_frame_hdr    PROGBITS         0000000000002014  00002014

       0000000000000044  0000000000000000   A       0     0     4

  [18] .eh_frame         PROGBITS         0000000000002058  00002058

       0000000000000108  0000000000000000   A       0     0     8

Note

The readelf --debug-dump option (sometimes shown as -w) may not work on all systems. If you encounter errors, use readelf --sections instead, which works reliably across different Linux distributions.

The .eh_frame and .eh_frame_hdr sections support C++ exceptions and stack unwinding. While our simple C program doesn’t use exceptions, these sections are included to support interoperability with C++ code and to enable proper stack traces during crashes.

The Size Breakdown: Where Do All Those Bytes Go?

Let’s break down where all those bytes go:

$ size --format=GNU hello

   text    data     bss     dec     hex filename

   1821     592       8    2421     975 hello

This gives us a high-level breakdown, but it doesn’t tell the full story. Let’s get a more detailed view:

$ size --radix=10 hello

hello  :

section              size    addr

.interp               28     792

.note.gnu.property    48     824

.note.gnu.build-id    36     872

[... additional sections ...]

Total               16696

The major contributors to our executable’s size are

Core Program Components (~2.5KB)

Machine Code (.text): The actual instructions

Read-Only Data (.rodata): String constants and other read-only values

Initialized Data (.data): Pre-initialized variables

BSS Section Placeholder (.bss): Space for uninitialized variables

Dynamic Linking Support (~4KB)

Dynamic Symbol Table: Information about external functions

String Tables: Names of functions, variables, and sections

Global Offset Table: Support for position-independent code

Procedure Linkage Table: Infrastructure for calling external functions

Runtime Support (~3KB)

Exception Handling Frames: For proper crash handling

Init/Fini Arrays: For program setup and cleanup

Debug Information: For debugging support

Metadata and Headers (~1KB)

ELF Header: The “table of contents”

Program Headers: Loading instructions

Section Headers: Section metadata

Alignment Padding (~6KB)

Required for performance and proper loading

Ensures data structures align with CPU cache lines and page boundaries

As shown in Figure 1-1, the breakdown reveals that only a small fraction of our executable (around 15%) is directly related to our source code. The rest is infrastructure that makes our program work in the Linux environment.

[picture]

Figure 1-1

Detailed Size Breakdown of Hello World Executable

Can We Make It Smaller?

Now that we understand what contributes to our executable’s size, you might wonder: Can we shrink it?

As shown in Figure 1-2, the answer is yes, through various optimization techniques.

[picture]

Figure 1-2

Size Comparison with Different Compilation Options

Basic Size Optimization

$ gcc -Os -o hello_small hello.c

$ strip hello_small

$ ls -l hello_small

-rwxr-xr-x 1 user user 14632 Oct 23 10:35 hello_small

The -Os flag optimizes for size, and strip removes debugging information. Together, these reduce our executable by about 12%.

Static Linking (for Comparison)

$ gcc -static -o hello_static hello.c

$ ls -l hello_static

-rwxr-xr-x 1 user user 832632 Oct 23 10:40 hello_static

Interestingly, static linking makes our executable much larger! This is because it includes all the C library code directly in our executable instead of relying on shared libraries. We’ll explore the trade-offs between static and dynamic linking in Chapter 9.

Advanced Optimization

$ gcc -Os -fdata-sections -ffunction-sections -Wl,--gc-sections -o hello_opt hello.c

$ strip hello_opt

$ ls -l hello_opt

-rwxr-xr-x 1 user user 14120 Oct 23 10:45 hello_opt

These advanced flags tell the compiler and linker to place each function and data item in its own section, then remove any sections that aren’t actually used (garbage collection). This gives us another 3.5% reduction in size.

Why Keep All This “Overhead”?

You might be wondering: Why include all this seemingly unnecessary infrastructure in a simple program? The answer lies in the benefits these components provide.

Dynamic Linking Benefits

Dynamic linking, despite requiring additional infrastructure in our executable, provides crucial advantages:

Code Sharing: Multiple programs can share the same copy of the C library in memory, reducing overall memory usage.

Security Updates: When vulnerabilities are found in libraries, they can be patched without rebuilding every application.

Smaller Executables: While our dynamic executable has more overhead than strictly necessary, it’s still much smaller than a statically linked version.

Runtime Infrastructure Benefits

The various initialization and support sections enable

Proper Program Startup: Setting up the C runtime environment correctly

Graceful Error Handling: Better crash reports and debugging information

Compatibility: Support for advanced language features and interoperability with C++

Platform Compatibility Benefits

The standardized ELF format and its sections ensure

Consistent Loading: Programs load reliably across different Linux systems.

Security Features: Support for Address Space Layout Randomization (ASLR) and other protections.

Debugging Support: Tools like gdb can provide meaningful information about crashes.

The Executable Lifecycle

To complete our understanding of the “Hello World” mystery, let’s examine how all these components come together when your program runs:

As illustrated in Figure 1-3, the executable lifecycle involves several stages.

[picture]

Figure 1-3

From Source to Executable Lifecycle

1.

Loading: The kernel reads the ELF header and program headers to determine how to load the program.

2.

Dynamic Linking: The dynamic linker resolves references to external functions, connecting your program to shared libraries.

3.

Initialization: Before main() is called, initialization code sets up the C runtime environment.

4.

Execution: Your main() function runs, calling functions from the C library as needed.

5.

Termination: After main() returns, finalization code cleans up resources.

Each of these stages requires specific sections and infrastructure in your executable, contributing to its overall size.

Summary

Our journey through the “Hello, World!” program has revealed that modern executables are sophisticated containers that package not just our code, but also the infrastructure needed to load the program correctly, link to shared libraries, initialize the runtime environment, handle errors gracefully, support debugging and profiling, and ensure platform compatibility.

Key Insights:

A simple “Hello, World!” program compiles to an executable roughly 249 times larger than its source code. This size disparity exists because executables contain infrastructure for loading, linking, initialization, and runtime support.

The ELF format organizes executables into sections, each serving a specific purpose in program execution. Core sections like .text (code), .rodata (read-only data), and .data (initialized variables) are just the beginning—modern executables include dozens of sections for dynamic linking, exception handling, and runtime support.

Dynamic linking, while adding overhead, provides benefits in terms of memory usage, security, and maintainability. Multiple programs can share library code in memory, and security updates can be applied without rebuilding every application.

Various optimization techniques can reduce executable size, but each comes with trade-offs. Size optimization flags, stripping debug information, and linker garbage collection can help, but static linking (which eliminates dynamic linking overhead) actually increases size dramatically.

Understanding executable internals empowers you to create more efficient, secure, and maintainable programs. By knowing what’s inside your executables, you can debug programs more effectively, optimize executable size and loading time when necessary, make informed decisions about linking and loading strategies, and write more efficient and maintainable code that leverages the runtime infrastructure.

In Chapter 2, we’ll dive deeper into the ELF format, exploring its headers and sections in much greater detail. This knowledge will form the foundation for understanding more advanced topics like linking, loading, and runtime behavior in later chapters.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_2

2. ELF: Demystifying the Executable Format

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

In Chapter 1, we discovered that our seemingly simple “Hello, World!” program resulted in a surprisingly large executable file. As you may recall, even that tiny program expanded into thousands of bytes of binary data. This revelation might have left you wondering: What’s actually inside these executable files, and how does the system know what to do with them?

Now it’s time to pull back the curtain and explore the format that makes this all possible: the Executable and Linkable Format (ELF). Throughout this chapter, we’ll try to understand the structure, purpose, and intricacies of ELF files—the standard binary format for executables, object files, and shared libraries on Linux systems.

The Role of ELF in the Linux Ecosystem

Before we get into the internal structure of ELF files, let’s take a moment to understand why this format matters so much in the Linux world.

ELF wasn’t the first executable format, nor will it be the last. IBM’s OS/360 had its object module format back in the 1960s, Microsoft Windows has its Portable Executable (PE) format, and macOS uses the Mach-O format. Each serves a similar purpose: to bridge the gap between the code you write and the instructions your computer executes.

So why does ELF stand out? Think of ELF as the universal language of binaries in the Linux world. It’s a carefully designed container that organizes your program’s code, data, and metadata in a way that the operating system and tools can understand. It provides the blueprint that tells Linux how to

Load your program into memory.

Resolve dependencies on libraries.

Find the entry point where execution should begin.

Allocate memory for variables.

Apply security protections.

ELF’s elegance lies in its flexibility. The same format works for object files waiting to be linked, executables ready to run, shared libraries that provide reusable functionality, and even core dumps that capture a program’s state after a crash. This unified approach simplifies the tool chain and makes the entire ecosystem more cohesive.

A Brief History of ELF

ELF wasn’t always the standard on Unix-like systems. In the early days of Unix, the a.out (assembler output) format was common. Later, COFF (Common Object File Format) was developed to address some of a.out’s limitations.

ELF emerged in System V Release 4 Unix in the early 1990s, designed to overcome the limitations of earlier formats:

a.out lacked support for shared libraries and had rigid section layouts.

COFF had limited extensibility and inefficient string tables.

ELF combined the best features of its predecessors while adding new capabilities. Its clean design and extensibility made it the natural choice when Linux was being developed, and it has remained the standard since the mid-1990s.

Today, ELF continues to evolve to accommodate new processor architectures, security features, and programming models while maintaining backward compatibility with existing tools and libraries.

The Anatomy of an ELF File

To understand ELF, I find it helpful to think of it as a well-organized book. Just as a book has a cover with basic information, a table of contents, chapters with content, and perhaps an index at the end, an ELF file has its own organizational structure.

Let’s create a simple program that we’ll use throughout this chapter to explore ELF:

// example.c

#include <stdio.h>

// Global variables

const char message[] = "Hello from ELF!";  // Read-only data

int initialized_var = 42;                  // Initialized data

int uninitialized_var;                     // Uninitialized data (BSS)

// A function to demonstrate code section

void print_message() {

    printf("%s\n", message);

}

int main() {

    uninitialized_var = initialized_var;

    print_message();

    return 0;

}

Now, let’s compile this program and begin our exploration:

$ gcc -g -o example example.c

The -g flag includes debugging information, which will give us more to examine. Now, let’s take a look at what we’ve created:

$ file example

Example: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=..., for GNU/Linux 3.2.0, with debug_info, not stripped

Already, this output tells us quite a bit. We’ve created a 64-bit ELF executable that’s dynamically linked and contains debugging information. But to truly understand what’s inside, we need to look deeper.

ELF File Structure Overview

At a high level, an ELF file consists of four main parts:

ELF Header: The “cover” of our book, containing basic information about the file

Program Header Table: Instructions for the system on how to prepare the program for execution

Sections: The actual content (code, data, etc.) organized into distinct categories

Section Header Table: A detailed “index” describing each section

Here’s a text representation of how these components are organized:

ELF:

  - ELF Header

    - Magic number

    - File type

    - Architecture

    - Entry point

    - Header locations

  - Program Header Table

    - PHDR segment

    - INTERP segment

    - LOAD segments

    - DYNAMIC segment

  - Sections

    - Code sections (.text, .init, .fini, .plt)

    - Data sections (.data, .rodata, .bss)

    - Symbol sections (.symtab, .dynsym, .strtab)

    - Relocation sections (.rela.text, .rela.dyn)

  - Section Header Table

    - Descriptions of all sections

    - Section names

    - Section types and flags

What makes this structure particularly clever is that it allows for two different views of the same file:

Execution View (Through Program Headers): Used by the operating system to load and execute the program

Linking View (Through Section Headers): Used by the linker and development tools to work with the file’s components

Let’s examine each part in detail.

The ELF Header: The File’s Identity Card

The ELF header sits at the beginning of every ELF file. Think of it as the ID card or passport of your program—it contains essential information about what type of file it is, which architecture it’s for, and where to find other parts of the file.

Let’s look at the ELF header of our example program:

$ readelf --file-header example

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 (Shared object file)

  Machine:                           Advanced Micro Devices X86-64

  Version:                           0x1

  Entry point address:               0x1060

  Start of program headers:          64 (bytes into file)

  Start of section headers:          14816 (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:         31

  Section header string table index: 30

Note

The exact values in your output may differ depending on your compiler version, distribution, and build options. The specific numbers (like section counts, addresses, and offsets) can vary, but the overall structure and meaning remain the same.

That’s a lot of information! Let’s break down the most important fields.

Magic Number

Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00

The first 16 bytes of every ELF file are the “magic number.” It’s like a secret handshake that identifies the file as an ELF. The first four bytes are always 0x7F, ’E’, ’L’, ’F’ (the ASCII values for the characters E, L, and F, preceded by the special byte 0x7F). These bytes are what allows the file command to recognize an ELF file.

The remaining bytes specify

File class (32-bit or 64-bit)

Data encoding (little or big endian)

ELF version

OS ABI (Application Binary Interface)

ABI version

Padding

File Type

Type: DYN (Shared object file)

This field tells us what kind of ELF file we’re looking at. Common types include

EXEC: A standard executable file

DYN: A shared object file (including position-independent executables)

REL: A relocatable file (object files waiting to be linked)

CORE: A core dump file (created when a program crashes)

You might be wondering why our executable shows up as “DYN” rather than “EXEC.” This depends on your compiler’s default settings. Modern compilers often create position-independent executables (PIE) by default for security reasons, which are marked as type DYN. On some systems (like Fedora 42 and Slackware 15), you may see “EXEC” instead if your compiler defaults to non-PIE executables. We’ll discuss PIE in more detail later in this chapter.

Machine Architecture

Machine: Advanced Micro Devices X86-64

This field specifies which processor architecture the file is built for. In our case, it’s x86-64 (also known as AMD64), the common 64-bit architecture found in most desktop and server computers today.

You might wonder why it’s called “Advanced Micro Devices X86-64” even when built on an Intel machine. This is because AMD was the first company to develop and release the 64-bit extension to the x86 architecture. Intel later adopted this architecture (calling it Intel 64), but the ELF standard uses the original AMD64 designation for compatibility. Both AMD and Intel processors use the same instruction set, so the name reflects the historical origin rather than the specific processor brand.

Other common values include

EM_386: 32-bit x86

EM_ARM: ARM processors

EM_AARCH64: 64-bit ARM

EM_RISCV: RISC-V

Entry Point

Entry point address: 0x1060

This is the memory address where program execution begins. When you run your program, the kernel looks at this value to know where to start execution.

Interestingly, this doesn’t point to the main() function as you might expect. It typically points to a function called _start, which sets up the environment before calling your main() function. We’ll explore this startup process in more detail in Chapter 4.

Header Locations

Start of program headers:          64 (bytes into file)

Start of section headers:          14816 (bytes into file)

These fields tell us where to find the program headers and section headers within the file. This information is crucial for tools that want to parse the ELF file.

Note

The exact byte offsets in your output may differ on different systems or with different compiler versions. The important thing is understanding what these fields represent.

Header Counts and Sizes

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:         31

These fields provide the dimensions of the various headers in the file. Our example has 13 program headers and 31 section headers.

Note

The number of headers may differ on your system. Different compiler versions, distributions, and build options can produce different numbers of sections and program headers. The important thing is understanding what these numbers represent.

Program Headers: Loading Instructions

If the ELF header is the ID card of your program, the program headers are its loading instructions. They tell the system how to prepare your program for execution—what parts to load into memory, where to place them, and what permissions to give them.

Let’s examine the program headers of our example:

$ readelf --program-headers example

Elf file type is DYN (Position-Independent Executable file)

Entry point 0x1060

There are 13 program headers, starting at offset 64

Program Headers:

  Type           Offset             VirtAddr           PhysAddr

                 FileSiz            MemSiz              Flags  Align

  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040

                 0x00000000000002d8 0x00000000000002d8  R      0x8

  INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318

                 0x000000000000001c 0x000000000000001c  R      0x1

      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000

                 0x0000000000000898 0x0000000000000898  R      0x1000

  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000

                 0x0000000000000235 0x0000000000000235  R E    0x1000

  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000

                 0x00000000000001a4 0x00000000000001a4  R      0x1000

  LOAD           0x0000000000002db8 0x0000000000003db8 0x0000000000003db8

                 0x0000000000000258 0x0000000000000260  RW     0x1000

  ...

These program headers define what are called “segments”—contiguous regions of the file that should be loaded into memory together. Let’s examine the most important types.

PHDR Segment

PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040

               0x00000000000002d8 0x00000000000002d8  R      0x8

This segment describes the program header table itself. It’s a bit like a self-reference—it tells the system where to find the very table it’s a part of.

INTERP Segment

INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318

               0x000000000000001c 0x000000000000001c  R      0x1

    [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]

This segment specifies the dynamic linker/interpreter that should be used to load this program. In our case, it’s /lib64/ld-linux-x86-64.so.2, which is the standard dynamic linker for 64-bit Linux programs.

You can view the contents of this segment using

$ readelf -p .interp example

String dump of section '.interp':

  [     0]  /lib64/ld-linux-x86-64.so.2

The dynamic linker is crucial for dynamically linked programs—it loads shared libraries, resolves symbols, and generally prepares the program to run. We’ll explore the dynamic linker in detail in a later chapter.

LOAD Segments

LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000

               0x0000000000000898 0x0000000000000898  R      0x1000

LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000

               0x0000000000000235 0x0000000000000235  R E    0x1000

LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000

               0x00000000000001a4 0x00000000000001a4  R      0x1000

LOAD           0x0000000000002db8 0x0000000000003db8 0x0000000000003db8

               0x0000000000000258 0x0000000000000260  RW     0x1000

LOAD segments are the heart of the program headers. They describe portions of the file that should be loaded into memory when the program runs. Each LOAD segment has

Offset: Where in the file the segment begins

VirtAddr: Where in memory to place the segment

FileSiz: How many bytes to read from the file

MemSiz: How many bytes the segment will occupy in memory (may be larger than FileSiz for segments with uninitialized data)

Flags: Permissions for the segment (R=read, W=write, E=execute)

Align: Alignment requirements for the segment

Notice that our program has four LOAD segments with different permissions:

Read-only segments (headers and data)

Read-execute segments (code)

Read-only segments (constant data)

Read-write segments (writeable data)

This separation is a security feature—by keeping code and data in separate segments with appropriate permissions, the system can enforce the “W^X” (write XOR execute) principle: memory should never be both writable and executable at the same time. This helps prevent certain types of attacks.

DYNAMIC Segment

While not shown in the shortened output above, the DYNAMIC segment contains information needed for dynamic linking:

$ readelf --dynamic example

Dynamic section at offset 0x2dc8 contains 27 entries:

  Tag        Type                         Name/Value

 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

 0x000000000000000c (INIT)               0x1000

 0x000000000000000d (FINI)               0x11f8

  ...

This segment tells the dynamic linker which shared libraries the program needs (libc.so.6 in our case), where to find initialization and cleanup code, and other runtime information such as symbol versioning requirements, relocation information, and library search paths.

Think of program headers as the “runtime view” of your program—they describe how the program will look in memory when it’s running, rather than how it’s organized in the file.

Sections: Where Program Components Live

While program headers describe how to load the program, section headers describe the internal organization of the file. If program headers are like chapters in a book, sections are like the individual topics within those chapters.

Let’s look at the sections in our example:

$ readelf --section-headers example

There are 31 section headers, starting at offset 0x39f0:

Section Headers:

  [Nr] Name              Type             Address           Offset

       Size              EntSize          Flags  Link  Info  Align

  [ 0]                   NULL             0000000000000000  00000000

       0000000000000000  0000000000000000           0     0     0

  [ 1] .interp           PROGBITS         0000000000000318  00000318

       000000000000001c  0000000000000000   A       0     0     1

  [ 2] .note.gnu.property NOTE             0000000000000338  00000338

       0000000000000030  0000000000000000   A       0     0     8

  [ 3] .note.gnu.build-id NOTE             0000000000000368  00000368

       0000000000000024  0000000000000000   A       0     0     4

  [ 4] .note.ABI-tag     NOTE             000000000000038c  0000038c

       0000000000000020  0000000000000000   A       0     0     4

  [ 5] .gnu.hash         GNU_HASH         00000000000003b0  000003b0

       0000000000000024  0000000000000000   A       6     0     8

  [ 6] .dynsym           DYNSYM           00000000000003d8  000003d8

       00000000000000a8  0000000000000018   A       7     1     8

  [ 7] .dynstr           STRTAB           0000000000000480  00000480

       0000000000000085  0000000000000000   A       0     0     1

  [ 8] .gnu.version      VERSYM           0000000000000506  00000506

       000000000000000e  0000000000000002   A       6     0     2

  [ 9] .gnu.version_r    VERNEED          0000000000000518  00000518

       0000000000000020  0000000000000000   A       7     1     8

  [10] .rela.dyn         RELA             0000000000000538  00000538

       00000000000000c0  0000000000000018   A       6     0     8

  [11] .rela.plt         RELA             00000000000005f8  000005f8

       0000000000000018  0000000000000018  AI       6    24     8

  [12] .init             PROGBITS         0000000000001000  00001000

       000000000000001b  0000000000000000  AX       0     0     4

  [13] .plt              PROGBITS         0000000000001020  00001020

       0000000000000020  0000000000000010  AX       0     0     16

  [14] .plt.got          PROGBITS         0000000000001040  00001040

       0000000000000010  0000000000000010  AX       0     0     16

  [15] .plt.sec          PROGBITS         0000000000001050  00001050

       0000000000000010  0000000000000010  AX       0     0     16

  [16] .text             PROGBITS         0000000000001060  00001060

       00000000000001a5  0000000000000000  AX       0     0     16

  [17] .fini             PROGBITS         0000000000001208  00001208

       000000000000000d  0000000000000000  AX       0     0     4

  [18] .rodata           PROGBITS         0000000000002000  00002000

       0000000000000014  0000000000000000   A       0     0     4

  ...

Note

The number of section headers may differ on your system. Different compiler versions and distributions can produce different numbers of sections (e.g., you might see 36 or 38 sections on some systems). The important thing is understanding the types of sections and their purposes.

That’s a lot of sections! Let’s focus on the most important ones and group them by purpose.

Code Sections

.text: This is where your program’s executable code lives. In our example, this would include the machine code for main() and print_message().

.init: Contains initialization code that runs before the main program.

.fini: Contains finalization code that runs when the program exits normally.

.plt: The Procedure Linkage Table, used for calling functions in shared libraries.

Let’s look at the code in the .text section:

$ objdump --disassemble --section=.text example

example:     file format elf64-x86-64

Disassembly of section .text:

0000000000001060 <_start>:

    1060:    f3 0f 1e fa              endbr64

    1064:    31 ed                    xor    %ebp,%ebp

    1066:    49 89 d1                 mov    %rdx,%r9

    1069:    5e                       pop    %rsi

    106a:    48 89 e2                 mov    %rsp,%rdx

    ...

00000000000011a9 <main>:

    11a9:    f3 0f 1e fa              endbr64

    11ad:    55                       push   %rbp

    11ae:    48 89 e5                 mov    %rsp,%rbp

    11b1:    8b 05 59 2e 00 00        mov    0x2e59(%rip),%eax        # 4010 <initialized_var>

    11b7:    89 05 57 2e 00 00        mov    %eax,0x2e57(%rip)        # 4014 <uninitialized_var>

    11bd:    e8 a7 ff ff ff           call   1169 <print_message>

    11c2:    b8 00 00 00 00           mov    $0x0,%eax

    11c7:    5d                       pop    %rbp

    11c8:    c3                       ret

0000000000001169 <print_message>:

    1169:    55                       push   %rbp

    116a:    48 89 e5                 mov    %rsp,%rbp

    116d:    48 8d 05 94 0e 00 00     lea    0xe94(%rip),%rax        # 2008 <message>

    1174:    48 89 c7                 mov    %rax,%rdi

    1177:    e8 e4 fe ff ff           call   1060 <puts@plt>

    117c:    90                       nop

    117d:    5d                       pop    %rbp

    117e:    c3                       ret

Now we can see the assembly code for our functions! Notice a few interesting things:

The main function loads initialized_var and stores it to uninitialized_var, just as our C code did.

The print_message function loads the address of our message string and passes it to puts().

There’s a function called _start that comes before main—this is the true entry point of the program.

Data Sections

.data: Contains initialized global variables (like our initialized_var)

.rodata: Contains read-only data (like our message string)

.bss: Reserves space for uninitialized global variables (like our uninitialized_var)

.got and .got.plt: The Global Offset Table, used for accessing data in shared libraries

Let’s look at the contents of these sections:

$ objdump --full-contents --section=.rodata example

Contents of section .rodata:

 2000 01000200 48656c6c 6f206672 6f6d2045  ....Hello from E

 2010 4c462100                             LF!.

$ objdump --full-contents --section=.data example

Contents of section .data:

 4000 00000000 2a000000                    ....*...

Notice that we can see our “Hello from ELF!” string in the .rodata section and our initialized_var value (42, or 0x2a in hexadecimal) in the .data section.

The .bss section is interesting because it doesn’t actually take up space in the file. It just reserves space in memory when the program runs:

$ readelf --section-headers example | grep bss

  [23] .bss              NOBITS           0000000000004010  00003010

       0000000000000008  0000000000000000  WA       0     0     1

The NOBITS type means there’s no data in the file for this section—it’s just a placeholder. This saves space in the executable file.

Symbol and Relocation Sections

.symtab: The symbol table, which maps names to addresses

.strtab: The string table, which stores the actual strings used in the symbol table

.dynsym and .dynstr: Dynamic versions of the symbol and string tables, used at runtime

.rela.text and similar: Relocation entries, which tell the linker how to adjust addresses

Let’s look at the symbol table:

$ readelf --symbols example

Symbol table '.dynsym' contains 7 entries:

   Num:    Value          Size Type    Bind   Vis      Ndx Name

     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND

     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.2.5 (2)

     2: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__

     3: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.34 (3)

     ...

Symbol table '.symtab' contains 67 entries:

   Num:    Value          Size Type    Bind   Vis      Ndx Name

     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND

     ...

    53: 0000000000004010     4 OBJECT  GLOBAL DEFAULT   23 uninitialized_var

    54: 0000000000004000     4 OBJECT  GLOBAL DEFAULT   22 initialized_var

    55: 0000000000002008    14 OBJECT  GLOBAL DEFAULT   18 message

    56: 0000000000001169    22 FUNC    GLOBAL DEFAULT   16 print_message

    57: 0000000000001060    38 FUNC    GLOBAL DEFAULT   16 _start

     ...

    62: 00000000000011a9    32 FUNC    GLOBAL DEFAULT   16 main

     ...

The symbol table is like a directory for your program—it maps names to addresses. This is how the debugger knows where to find your functions and variables.

Notice that there are two symbol tables:

.dynsym is a smaller table used at runtime, containing only the symbols needed for dynamic linking.

.symtab is a more complete table used for debugging and linking, containing all symbols.

Our variables and functions show up in the .symtab with their addresses and sizes.

Special Sections

.interp: Specifies the dynamic linker (as we saw earlier)

.note.*: Contains notes with additional information (like ABI requirements)

.debug_*: Contains debugging information (only present because we used the -g flag)

.gnu.*: Various GNU-specific sections for things like version checking

Sections are typically organized into segments (as defined by the program headers) when the program is loaded into memory. Several sections might be combined into a single segment with the same permissions.

The Linking Process: How ELF Files Come Together

Now that we understand the internal structure of ELF files, let’s take a step back and look at how they’re created. The ELF format isn’t just for executables—it’s used throughout the compilation process:

When you compile a C file with -c, you get a relocatable object file (.o) in ELF format.

When you link these object files together, you get an executable file, also in ELF format.

Shared libraries (.so files) that your program depends on are also in ELF format.

Let’s see these different ELF types in action.

Creating a Relocatable Object File

$ gcc -c -o example.o example.c

$ file example.o

example.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

This object file contains our code and data, but it’s not ready to run yet. It needs to be linked with system libraries first.

Creating a Shared Library

$ gcc -shared -fPIC -o libexample.so example.c

$ file libexample.so

libexample.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, not stripped

This shared library could be used by multiple programs. The -fPIC flag creates position-independent code, which can be loaded at any address.

Creating a Static Executable

$ gcc -static -o example_static example.c

$ file example_static

example_static: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, not stripped

This static executable contains everything it needs to run, including all library functions. It doesn’t need a dynamic linker.

Let’s compare the sizes of these different ELF files:

$ ls -lh example.o example libexample.so example_static

-rwxr-xr-x 1 user user  16K Apr 15 14:23 example

-rw-r--r-- 1 user user 4.0K Apr 15 14:23 example.o

-rwxr-xr-x 1 user user  16K Apr 15 14:23 libexample.so

-rwxr-xr-x 1 user user 884K Apr 15 14:23 example_static

Note

The exact file sizes on your system may differ from these examples. Different compiler versions, distributions, and build options can produce different sizes. However, the relative sizes should be similar—the static executable will be much larger than the dynamic executable, and the object file will be smaller than the executable.

Notice how much larger the static executable is compared to the others. It includes all the library code that the dynamic executable gets at runtime.

Let’s look at how these different files handle external references. In our example, we call printf(), which is defined in the C library. Let’s see how the different ELF files handle this:

$ readelf --relocs example.o

Relocation section '.rela.text' at offset 0x2a8 contains 2 entries:

  Offset          Info           Type           Sym. Value    Sym. Name + Addend

00000000001d  00080000000a R_X86_64_32       0000000000000000 .rodata + 0

000000000028  000b00000002 R_X86_64_PC32     0000000000000000 printf - 4

$ readelf --relocs example

Relocation section '.rela.dyn' at offset 0x538 contains 8 entries:

  Offset          Info           Type           Sym. Value    Sym. Name + Addend

000000003db8  000000000008 R_X86_64_RELATIVE                    1169

...

$ readelf --relocs example_static

No relocations in file.

Note

Relocations are placeholders that tell the linker how to patch addresses in object files. When the compiler generates code, it doesn't know the final memory addresses of functions and variables, so it leaves placeholders (relocations) that the linker fills in with the correct addresses.

In the object file (example.o), there’s a relocation entry for printf, which means “I don’t know where printf is yet, but I need it.”

In the dynamic executable (example), there are relocations, but they’re of type R_X86_64_RELATIVE and R_X86_64_GLOB_DAT, which are resolved by the dynamic linker at runtime.

In the static executable (example_static), there are no relocations because everything is already resolved.

This demonstrates the flexibility of the ELF format—it can represent code and data at different stages of the compilation and linking process.

Security Implications of ELF Structure

The design of ELF has significant security implications. Let’s explore some of the key security features that are enabled or enforced by the ELF structure.

Memory Permissions and W^X

As we saw earlier, ELF segments can have different permissions (read, write, execute). This allows the operating system to enforce the principle of “W^X” (write XOR execute), which means memory should never be both writable and executable at the same time.

Looking at our example’s segments:

LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000

               0x0000000000000235 0x0000000000000235  R E    0x1000

LOAD           0x0000000000002db8 0x0000000000003db8 0x0000000000003db8

               0x0000000000000258 0x0000000000000260  RW     0x1000

The first segment contains code (.text) and is read-execute (R E), while the segment containing data (.data, .bss) is read-write (RW). This separation is a key defense against certain types of attacks, particularly those that try to inject and execute malicious code.

Position-Independent Executables (PIE)

You might have noticed that our executable is of type DYN rather than EXEC. This indicates that it’s a position-independent executable (PIE), which means it can be loaded at any address in memory.

PIE is now the default on most Linux distributions because it enables Address Space Layout Randomization (ASLR), a security feature that loads the program and its libraries at random addresses each time it runs. This makes it harder for attackers to predict where specific code or data will be in memory, complicating many types of attacks.

Let’s verify that ASLR is active on our system:

$ cat /proc/sys/kernel/randomize_va_space

2

A value of 2 means full ASLR is enabled. Now, let’s run our program and see where it gets loaded. Since the program exits quickly, we’ll use a different approach to inspect its memory map:

$ ./example &

PID=$!

sleep 0.1

cat /proc/$PID/maps | head -1

kill $PID 2>/dev/null

Note

Replace $PID with the actual process ID if the above doesn't work on your system. You can also run the program in one terminal and check /proc/PID/maps in another terminal, replacing PID with the actual process ID shown when you run ./example &.

If you run the program multiple times, you’ll see it loaded at different addresses each time. This is ASLR in action, and it’s made possible by the PIE feature of ELF.

Stack Canaries

Modern compilers add another security feature called “stack canaries” to detect stack buffer overflows. These are special values placed between local variables and the return address on the stack. If a buffer overflow occurs, the canary gets overwritten, which the program can detect before executing potentially malicious code.

Stack canaries are enabled by default in GCC when compiling with certain flags (like -fstack-protector or -fstack-protector-strong). The compiler automatically inserts code to check the canary value, and if it’s been modified, the program calls __stack_chk_fail to abort execution.

We can see if our executable uses stack canaries by looking for references to __stack_chk_fail:

$ readelf --symbols example | grep -i stack

     8: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __stack_chk_fail@GLIBC_2.4

This shows that our program does use stack canaries for protection. The presence of this symbol indicates that the compiler has inserted stack protection code, and if a buffer overflow is detected, the program will call this function to abort safely.

RELRO (RELocation Read-Only)

Another security feature is RELRO (RELocation Read-Only), which makes the Global Offset Table (GOT) read-only after all the relocations have been performed. This prevents attackers from overwriting entries in the GOT to redirect function calls.

We can check if our executable uses RELRO:

$ readelf --dynamic example | grep -E "(FLAGS|RELRO)"

 0x000000000000001e (FLAGS)              BIND_NOW

 0x6ffffffb (FLAGS_1)                    Flags: NOW PIE

The BIND_NOW flag indicates that our executable uses “full RELRO” (also called “NOW”), which means all relocations are resolved at load time and the GOT is made read-only immediately. This provides the strongest protection but requires all symbols to be resolved at startup.

These security features show how the design of ELF allows for a variety of protection mechanisms that are crucial for secure computing in today’s world.

Working with ELF Files: Practical Examples

Now that we understand the structure of ELF files, let’s explore some practical ways to work with them. The Linux ecosystem provides a rich set of tools for manipulating ELF files.

Adding Custom Data to an ELF File

You can add your own sections to an ELF file using objcopy. This can be useful for embedding data like configuration files, icons, or other resources in your executable:

$ echo "This is my custom data" > mydata.txt

$ objcopy --add-section .mydata=mydata.txt example example_modified

$ readelf --section-headers example_modified | grep mydata

  [31] .mydata           PROGBITS         0000000000000000  00003a00

       0000000000000016  0000000000000000           0     0     1

Now you can read this data from your program:

extern char _binary_mydata_txt_start;

extern char _binary_mydata_txt_end;

void print_mydata() {

    char *start = &_binary_mydata_txt_start;

    char *end = &_binary_mydata_txt_end;

    printf("Custom data: %.*s\n", (int)(end - start), start);

}

Stripping Debug Information

Debug information is valuable during development but increases the size of your executable. For production, you might want to strip this information:

$ ls -lh example

-rwxr-xr-x 1 user user 16K Apr 15 14:23 example

$ strip --strip-debug example

$ ls -lh example

-rwxr-xr-x 1 user user 12K Apr 15 14:24 example

You can also save the debug information separately:

$ objcopy --only-keep-debug example example.debug

$ strip --strip-debug example

$ objcopy --add-gnu-debuglink=example.debug example

The --add-gnu-debuglink option adds a reference to the separate debug file back into the stripped executable. This doesn’t restore the debug information directly into the executable (which is why the file size doesn’t return to its original value), but it tells debuggers where to find the debug information when needed. This allows you to distribute a smaller executable while keeping the debug information available for troubleshooting.

Analyzing Dependencies

You can check which shared libraries an executable depends on using ldd:

$ ldd example

linux-vdso.so.1 (0x00007ffcf9fb4000)

libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f54e47bb000)

/lib64/ld-linux-x86-64.so.2 (0x00007f54e49b4000)

This shows that our example depends on libc.so.6 (the C library) and uses ld-linux-x86-64.so.2 as its dynamic linker.

Viewing the Dynamic Symbol Table

We can use nm to view the symbols in our executable:

$ nm example

0000000000004010 B __bss_start

0000000000004010 b completed.0

                 w __cxa_finalize@GLIBC_2.2.5

0000000000004000 D __data_start

...

0000000000004000 D initialized_var

...

0000000000002008 R message

00000000000011a9 T main

...

0000000000001169 T print_message

...

0000000000004010 B uninitialized_var

The letters indicate the type of symbol:

T for text (code) section

D for initialized data

B for BSS (uninitialized data)

R for read-only data

U for undefined symbols (imported from libraries)

Disassembling Functions

We can use objdump to disassemble specific functions:

$ objdump --disassemble=main example

...

00000000000011a9 <main>:

    11a9:    f3 0f 1e fa              endbr64

    11ad:    55                       push   %rbp

    11ae:    48 89 e5                 mov    %rsp,%rbp

    11b1:    8b 05 59 2e 00 00        mov    0x2e59(%rip),%eax        # 4010 <initialized_var>

    11b7:    89 05 57 2e 00 00        mov    %eax,0x2e57(%rip)        # 4014 <uninitialized_var>

    11bd:    e8 a7 ff ff ff           call   1169 <print_message>

    11c2:    b8 00 00 00 00           mov    $0x0,%eax

    11c7:    5d                       pop    %rbp

    11c8:    c3                       ret

This shows the assembly instructions for our main() function.

Tracing System Calls

We can use strace to see the system calls a program makes when it runs:

$ strace ./example

execve("./example", ["./example"], 0x7ffc9487d7e0 /* 66 vars */) = 0

brk(NULL)                               = 0x557a5a43c000

arch_prctl(ARCH_SET_FS, 0x557a5a43b540) = 0

...

write(1, "Hello from ELF!\n", 16)       = 16

...

exit_group(0)                           = ?

+++ exited with 0 +++

This helps us understand how our program interacts with the operating system.

These tools allow us to inspect, modify, and analyze ELF files in various ways, which is invaluable for debugging, optimization, and security analysis.

ELF in Different Contexts: A Comparison

We’ve seen that ELF is a versatile format used for different types of files. Let’s compare how ELF is used in different contexts:

Relocatable Files (.o)

Object files are produced by the compiler and are meant to be linked together to form executables or libraries. They have the following characteristics:

They contain unresolved references (relocations) to symbols that will be defined in other files.

They don’t have a program header table because they’re not meant to be executed directly.

Their section header table is crucial for the linker to understand the structure of the file.

They often contain additional sections for debugging and relocation information.

Executable Files

Executables are meant to be loaded and run by the operating system. They have

A program header table that tells the system how to create a process image

An entry point address where execution should begin

Either all references resolved (static executables) or references to dynamic libraries (dynamic executables)

For dynamic executables, an interpreter section specifying the dynamic linker

Shared Objects (.so)

Shared libraries are loaded by multiple programs at runtime. They are characterized by

Position-independent code that can be loaded at any address

A program header table (like executables) so they can be loaded

Symbol tables for both the symbols they define and the symbols they use

Versioning information to maintain compatibility across different versions

Core Dumps

Core dumps are created when a program crashes and contain the state of the program at the time of the crash. They have

Program headers describing the memory segments of the original process

Contents of the memory segments, including stack, heap, and program code

Additional notes with information about the crash

This versatility is one of ELF’s strengths—it provides a unified format for different stages of a program’s lifecycle, from compilation to execution and even post-crash analysis.

The Evolution of ELF: Past, Present, and Future

The ELF format was designed with extensibility in mind, and it has evolved over time to accommodate new features and requirements. Let’s look at some of the extensions that have been added to ELF over the years:

Historical Evolution

ELF was introduced in System V Release 4 in the early 1990s, replacing older formats like a.out and COFF. Since then, it has been extended in various ways:

GNU Extensions: The GNU toolchain added sections like .gnu.hash for faster symbol lookup, .gnu.version for symbol versioning, and .eh_frame for exception handling.

Linux-Specific Extensions: Linux added support for sections like .note.ABI-tag to specify which kernel version the program requires.

Architecture-Specific Extensions: As ELF has been ported to new architectures like ARM, RISC-V, and POWER, architecture-specific extensions have been added.

Modern Features

In recent years, several new features have been added to ELF to address modern requirements:

Security Extensions: We’ve already discussed features like PIE, RELRO, and stack canaries. Others include Control Flow Integrity (CFI) and Shadow Stacks.

Performance Optimizations: Features like DT_GNU_HASH for faster symbol lookup and DT_NEEDED ordering for more efficient library loading.

Debugging Enhancements: DWARF debug information (stored in .debug_* sections) has been extended to support new language features and debugging techniques.

Future Directions

The ELF format continues to evolve to meet new challenges:

Container Support: As containerization becomes more prevalent, there are efforts to make ELF binaries more container-friendly, with features like reduced dependencies and better support for layered filesystems.

Security Hardening: Work continues on new security features like memory tagging and improved compartmentalization.

Cross-Platform Compatibility: As new architectures emerge, ensuring that ELF remains a viable format across a diverse ecosystem is an ongoing challenge.

The longevity of ELF is a testament to its robust and extensible design. Despite being over 30 years old, it continues to serve as the foundation for binary compatibility in the Linux ecosystem.

Case Study: Analyzing a Real-World Program

To solidify our understanding of ELF, let’s analyze a real-world program: the /bin/ls command that we use every day.

$ file /bin/ls

/bin/ls: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=...

This tells us that ls is a 64-bit ELF executable that’s dynamically linked. Let’s dig deeper:

$ readelf --file-header /bin/ls | grep Entry

  Entry point address:               0x5850

$ readelf --dynamic /bin/ls | grep NEEDED

 0x0000000000000001 (NEEDED)             Shared library: [libselinux.so.1]

 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

$ readelf --section-headers /bin/ls | grep -E '\.[a-z]+$' | head

  [12] .note.gnu.build-id NOTE             0000000000000238  00000238

  [13] .note.ABI-tag     NOTE             0000000000000258  00000258

  [14] .gnu.hash         GNU_HASH         0000000000000278  00000278

  [15] .dynsym           DYNSYM           0000000000000648  00000648

  [16] .dynstr           STRTAB           0000000000001788  00001788

  [17] .gnu.version      VERSYM           0000000000002132  00002132

  [18] .gnu.version_r    VERNEED          0000000000002290  00002290

  [19] .rela.dyn         RELA             0000000000002380  00002380

  [20] .rela.plt         RELA             0000000000003250  00003250

We can see that ls depends on libselinux.so.1 and libc.so.6, and it has a similar structure to our example program, but with more sections.

Let’s look at its program headers:

$ readelf --program-headers /bin/ls | grep -A 2 LOAD

  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000

                 0x0000000000004628 0x0000000000004628  R      0x1000

  LOAD           0x0000000000005000 0x0000000000005000 0x0000000000005000

                 0x000000000000c79d 0x000000000000c79d  R E    0x1000

  LOAD           0x0000000000012000 0x0000000000012000 0x0000000000012000

                 0x0000000000004d1c 0x0000000000004d1c  R      0x1000

  LOAD           0x0000000000016db8 0x0000000000017db8 0x0000000000017db8

                 0x00000000000008b0 0x0000000000002420  RW     0x1000

Again, we see the separation of segments with different permissions: read-only, read-execute, read-only, and read-write.

Finally, let’s look at some of the symbols in ls:

$ nm /bin/ls | grep -E ' T ' | head

0000000000005850 T _start

000000000000bc10 T __assert_fail

000000000000bc30 T __assert_perror_fail

000000000000bc60 T __ctype_b_loc

000000000000bc70 T __ctype_get_mb_cur_max

000000000000bc80 T __ctype_tolower_loc

000000000000bc90 T __ctype_toupper_loc

000000000000bca0 T __errno_location

000000000000bcb0 T __fpending

000000000000bcc0 T __fprintf_chk

This shows some of the functions in the .text section of ls. We can see that it has a _start function as its entry point, followed by various library functions.

This real-world example reinforces what we’ve learned about ELF structure and shows that the principles apply to all ELF executables, from our simple examples to complex utilities like ls.

Summary

In this chapter, we’ve taken a deep dive into the Executable and Linkable Format (ELF), exploring its structure, purpose, and significance in the Linux ecosystem. Here are the key points:

ELF Fundamentals

ELF is the standard binary format for executables, object files, and shared libraries on Linux systems.

An ELF file consists of an ELF header, program headers, sections, and section headers.

The ELF header contains basic information about the file, including its type, architecture, and entry point.

Program Headers and Segments

Program headers define segments for loading into memory, with different permissions for code and data.

LOAD segments contain the actual program code and data that get loaded into memory.

The separation of code and data segments enables security features like W^X (write XOR execute).

Sections and Organization

Sections organize the file’s content into categories like code (.text), initialized data (.data), and read-only data (.rodata).

The .bss section reserves space for uninitialized data without taking up file space.

Symbol tables (.symtab, .dynsym) map names to addresses for linking and debugging.

Relocation sections tell the linker how to adjust addresses when combining object files.

Security Features

ELF’s design enables important security features like W^X, ASLR (through PIE), stack canaries, and RELRO.

Position-independent executables (PIE) allow programs to be loaded at random addresses, making attacks harder.

Stack canaries detect buffer overflows before they can be exploited.

RELRO protects the Global Offset Table from being overwritten.

ELF Throughout the Build Process

The same ELF format is used throughout the compilation process, from object files to executables and shared libraries.

Object files contain relocations that are resolved during linking.

Dynamic executables rely on the dynamic linker to resolve symbols at runtime.

Static executables contain all code and have no external dependencies.

Tools and Analysis

A rich ecosystem of tools allows developers to inspect, analyze, and manipulate ELF files for various purposes.

Tools like readelf, objdump, nm, and ldd provide different views of ELF files.

Understanding ELF structure is crucial for debugging, optimization, and security analysis.

Understanding ELF is crucial for low-level programming on Linux. It provides the foundation for everything from compiler optimizations to security hardening techniques. As we continue our journey in the following chapters, we’ll build on this knowledge to explore how ELF sections are organized, how dynamic linking works, and how the startup process unfolds when an ELF executable is run.

In the next chapter, we’ll dive deeper into the specific sections that make up an ELF file, exploring their content, purpose, and relationships in greater detail. We’ll see how different types of code and data are organized within these sections and how this organization affects the behavior of your programs.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_3

3. Where Your C Code Lives: Understanding ELF Sections

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

When you compile a C program, your code and data don’t just get thrown together randomly in the resulting executable. Instead, they’re carefully organized into different sections, each with its own purpose and characteristics. Think of an ELF file as a well-organized suitcase where different compartments hold specific items—clothes in one section, toiletries in another, and electronics in a third. This organization makes it easier to find what you need and keeps everything protected appropriately.

In this chapter, we’ll get into the most important ELF sections and see exactly where different parts of your C code end up. By understanding these sections, you’ll gain powerful insights into how your programs are laid out in memory, which is crucial for debugging, optimization, and security.

Sections vs. Segments: A Quick Reminder

Before we get in, let’s briefly revisit the distinction between sections and segments that we covered in Chapter 2:

Sections are the finer-grained units used primarily during linking and for tools like debuggers. They’re described in the Section Header Table.

Segments are coarser-grained units used at runtime by the loader. They’re described in the Program Header Table.

Multiple sections with similar characteristics (like permissions) are typically grouped into a single segment at runtime. In this chapter, we’ll focus mainly on sections, but keep in mind how they map to segments for execution.

The Four Primary Sections

Every ELF executable typically contains four main sections that hold different types of program content:

.text: Contains executable code

.rodata: Holds read-only data (constants)

.data: Contains initialized global and static variables

.bss: Reserved for uninitialized global and static variables

Let’s explore each of these sections in detail with practical examples.

The .text Section: Where Your Code Lives

The .text section contains the actual machine code instructions that your program executes. This is where all your functions, from main() to the smallest helper function, live after compilation.

Let’s look at a simple example:

// example1.c

int add(int a, int b) {

    return a + b;

}

int main() {

    int result = add(5, 3);

    return result;

}

After compiling this code (gcc -o example1 example1.c), we can examine the .text section using objdump:

$ objdump -d -j .text example1

example1:     file format elf64-x86-64

Disassembly of section .text:

*(Note: The full output includes many more functions and initialization code. This output has been truncated for brevity - showing only the key functions we defined.)*

0000000000001129 <add>:

    1129:    55                      push   %rbp

    112a:    48 89 e5                mov    %rsp,%rbp

    112d:    89 7d fc                mov    %edi,-0x4(%rbp)

    1130:    89 75 f8                mov    %esi,-0x8(%rbp)

    1133:    8b 55 fc                mov    -0x4(%rbp),%edx

    1136:    8b 45 f8                mov    -0x4(%rbp),%eax

    1139:    01 d0                   add    %edx,%eax

    113b:    5d                      pop    %rbp

    113c:    c3                      ret

000000000000113d <main>:

    113d:    55                      push   %rbp

    113e:    48 89 e5                mov    %rsp,%rbp

    1141:    48 83 ec 10             sub    $0x10,%rsp

    1145:    be 03 00 00 00          mov    $0x3,%esi

    114a:    bf 05 00 00 00          mov    $0x5,%edi

    114f:    e8 d5 ff ff ff          call   1129 <add>

    1154:    89 45 fc                mov    %eax,-0x4(%rbp)

    1157:    8b 45 fc                mov    -0x4(%rbp),%eax

    115a:    c9                      leave

    115b:    c3                      ret

This output shows the actual machine code instructions for both our add and main functions. Each function’s code is stored sequentially in the .text section, with the addresses shown on the left.

Notice a few interesting things here:

The function prologue and epilogue (the push %rbp, mov %rsp, %rbp at the beginning and pop %rbp, ret at the end)

How parameters are passed in registers (%edi for the first argument, %esi for the second)

The actual add instruction at address 1139

The function call at address 114f that jumps to our add function

Section Permissions: The .text section is marked as read-only and executable. (Note: This information is not visible in the objdump output above. We determine section permissions by examining the section flags using readelf, which we’ll demonstrate in the “Section Permissions and Memory Protection” section below.) This is a security feature—preventing code from being modified during execution helps prevent certain types of attacks. If you try to modify the code at runtime, you’ll trigger a segmentation fault.

Function Placement and Performance

The order of functions within the .text section can actually impact performance due to CPU caching behavior. Functions that call each other frequently should ideally be close to each other in memory. Modern compilers use profile-guided optimization (PGO) to rearrange functions based on actual execution patterns, potentially improving cache locality.

(We’ll explore how to control function placement using attributes in the “Common Pitfalls and How to Avoid Them” section, after you have the full context of how sections work.)

The .rodata Section: Constants and String Literals

The .rodata (read-only data) section stores constant values that shouldn’t be modified during program execution. The most common occupants of this section are

String literals

Constant global variables (declared with const)

Jump tables or lookup tables that don’t change

Let’s look at an example:

// example2.c

const char* message = "Hello, World!";

const int magic_number = 42;

int main() {

    return magic_number;

}

We can examine the .rodata section:

$ objdump -s -j .rodata example2

example2:     file format elf64-x86-64

Contents of section .rodata:

 2000 48656c6c 6f2c2057 6f726c64 2100      Hello, World!.

*(Note: Your output may show additional lines with other constants or padding. This output has been truncated for brevity - showing only the relevant string literal.)*

Note how our string constant “Hello, World!” is stored in the .rodata section. The magic number is typically handled by the compiler as an immediate value in the instructions rather than being stored separately in .rodata (for optimization reasons).

The .rodata section, like .text, is marked as read-only, but unlike .text, it’s not executable. Attempting to execute data from .rodata as code would cause a segmentation fault—another important security feature.

Switch Statements and Jump Tables

An interesting feature of the .rodata section is that it often contains jump tables for switch statements with many cases. For example:

// switch_example.c

int process_command(int cmd) {

    switch(cmd) {

        case 0: return 100;

        case 1: return 101;

        case 2: return 102;

        case 3: return 103;

        case 4: return 104;

        // ... many more cases

        default: return -1;

    }

}

For switch statements with many contiguous cases, the compiler often generates a jump table in .rodata rather than a series of comparisons. This makes execution more efficient for large switch statements.

The .data Section: Initialized Global Variables

The .data section contains global and static variables that are initialized with non-zero values. These variables have reserved space in the executable file, and their initial values are loaded into memory when the program starts.

Here’s an example:

// example3.c

int global_counter = 100;

static int static_value = 42;

int main() {

    global_counter++;

    return static_value;

}

Let’s look at the .data section:

$ objdump -s -j .data example3

example3:     file format elf64-x86-64

Contents of section .data:

*(Note: Output format and addresses may vary between systems. Your output might look like:)*

 4000 64000000 2a000000                    d...*...

*(Or on some systems, you might see padding bytes first:)*

 4018 00000000 00000000 00000000 00000000  ................

 4028 64000000 2a000000                    d...*...

*(The exact addresses and padding depend on your system's default linker layout.)*

Here we can see our initialized values: 100 (0x64) and 42 (0x2a) stored in little-endian format (bytes reversed). Each takes 4 bytes because they’re integers.

Unlike .text and .rodata, the .data section is marked as readable and writable, allowing the program to modify these values during execution.

Memory Efficiency and .data

One thing to be aware of is that everything in the .data section takes up space in the executable file. If you have large initialized arrays, they can significantly increase your program’s size:

// large_data.c

int big_array[10000] = {1, 2, 3, 4, 5}; // Rest initialized to 0

int main() {

    return big_array[0];

}

In this case, even though most of the array is zeros, the entire array would be stored in the file—first in .data for the non-zero elements, then the rest would actually go in .bss (which we’ll cover next). This is why it’s generally more efficient to initialize large arrays programmatically rather than statically when possible.

The .bss Section: Uninitialized Variables

The .bss section is unique because it doesn’t actually take up space in the executable file. It only specifies how much space should be allocated for uninitialized global and static variables when the program loads. This is a memory optimization—why store a bunch of zeros in the file when you can just tell the loader to reserve the space and initialize it to zero at runtime?

Let’s see an example:

// example4.c

int uninitialized_array[1000];

static char buffer[4096];

int main() {

    uninitialized_array[0] = 42;

    buffer[0] = 'A';

    return 0;

}

We can examine the .bss section’s size:

$ readelf -S example4 | grep bss

 [24] .bss              NOBITS           0000000000004010 002020 001010 00  WA  0   0 32

The NOBITS type indicates that this section doesn’t occupy any space in the file. The size is 0x1010 bytes (4112 in decimal), which accounts for our array (4000 bytes) and buffer (4096 bytes), with appropriate alignment.

When the program starts, the loader allocates this memory and initializes it to zero automatically. This is why uninitialized global variables are guaranteed to be zero (unlike local variables, which have indeterminate values if not initialized).

The .bss Magic: Zero Initialization Without File Bloat

The .bss section is a clever optimization. Consider two programs:

// program1.c

int huge_array[1000000] = {0}; // Explicitly initialized to zeros

// program2.c

int huge_array[1000000]; // Implicitly initialized to zeros

Both programs will behave identically—huge_array will be filled with zeros. But their file sizes will be dramatically different:

In program1.c, those million zeros need to be stored in the .data section, bloating the file.

In program2.c, the array is in .bss, requiring just a size specification (typically 8 bytes) in the file.

This is why you’ll often see this pattern in real-world code, especially in embedded systems where binary size matters:

static char large_buffer[BUFFER_SIZE]; // Goes in .bss

// Later in the code

memset(large_buffer, 0, BUFFER_SIZE); // Explicit zero initialization if needed

Even though large_buffer is already zero-initialized by virtue of being in .bss, some programmers explicitly zero it out for clarity or to reset it between uses.

Memory Layout

Let’s visualize how these sections are laid out in memory when a program is loaded.

[picture]

Figure 3-1

Memory Layout

Above Diagram shows the typical layout of these sections in memory, from lower addresses at the top to higher addresses at the bottom. The .text and .rodata sections are typically placed at lower memory addresses, followed by .data and .bss sections. Above these, we find the heap (for dynamic memory allocation) and stack (for local variables and function calls).

The arrows between sections don’t imply direct connections but rather the general progression of memory addresses. Also, modern systems often include additional protections like Address Space Layout Randomization (ASLR), which randomizes the actual addresses to prevent exploitation.

Section Permissions and Memory Protection

Each section has specific permissions that determine how it can be accessed at runtime:

.text: Read + Execute

.rodata: Read-only

.data: Read + Write

.bss: Read + Write

We can verify these permissions:

$ readelf -S example1 | grep -E "text|rodata|data|bss"

 [14] .text             PROGBITS         0000000000001129 001129 000033 00  AX  0   0 16

  [15] .rodata           PROGBITS         0000000000002000 002000 000010 00   A  0   0  4

  [23] .data             PROGBITS         0000000000004000 003000 000010 00  WA  0   0  8

  [24] .bss              NOBITS           0000000000004010 003010 001000 00  WA  0   0 32

The flags column (the eighth column, showing values like “AX”, “A”, “WA”) indicates section permissions:

A = Allocatable (space should be allocated during process loading)

X = Executable (can contain code that gets executed)

W = Writable (can be modified at runtime)

These permissions are enforced by the memory management unit (MMU) in your processor, providing hardware-level protection. If your program tries to violate these permissions (like writing to .text or executing from .data), you’ll get a segmentation fault.

Security Through Section Permissions

This separation of sections with different permissions is a critical security feature. By making code executable but not writable, and data writable but not executable, modern systems implement a principle called “W^X” (Write XOR Execute)—memory can be either writable or executable, but not both.

This helps prevent various attacks, particularly those that try to inject malicious code into data areas and then execute it. For example, a buffer overflow might let an attacker write beyond the intended boundaries of an array, but if that array is in a non-executable section, the attacker can’t execute the injected code.

Let's Get Hands-On: A Complete Example

Let’s work through a more comprehensive example that touches all four main sections:

// sections_demo.c

#include <stdio.h>

// .rodata section

const char *message = "Hello, ELF sections!";

const int magic = 42;

// .data section

int initialized_var = 100;

static double pi = 3.14159;

// .bss section

int uninitialized_array[1000];

static char buffer[4096];

// .text section

void initialize_buffer() {

    for(int i = 0; i < 10; i++) {

        buffer[i] = 'A' + i;

    }

}

int main() {

    printf("%s\n", message);

    printf("Magic: %d\n", magic);

    printf("Initialized: %d\n", initialized_var);

    printf("Pi: %f\n", pi);

    initialize_buffer();

    printf("Buffer starts with: %c%c%c\n", buffer[0], buffer[1], buffer[2]);

    uninitialized_array[0] = 42;

    printf("Array[0]: %d\n", uninitialized_array[0]);

    return 0;

}

Let’s compile and analyze this program:

$ gcc -o sections_demo sections_demo.c

$ size sections_demo

  text    data     bss     dec     hex filename

   1679     616    8192   10487    28f7 sections_demo

The size command gives us a quick overview of the section sizes. Here, our .text section is 1679 bytes, .data is 616 bytes, and .bss is 8192 bytes. The total size in decimal is 10487 bytes.

Now let's look at each section in more detail:

$ objdump -s -j .rodata sections_demo | head -20

Contents of section .rodata:

 2000 01000200 48656c6c 6f2c2045 4c462073  ....Hello, ELF s

 2010 65637469 6f6e7321 00257300 0a4d6167  ections!.%s..Mag

 2020 69633a20 25640a00 496e6974 69616c69  ic: %d..Initiali

 2030 7a65643a 2025640a 00506920 25660a00  zed: %d..Pi %f..

 2040 42756666 65722073 74617274 73207769  Buffer starts wi

 2050 74683a20 25632563 25630a00 41727261  th: %c%c%c..Arra

 2060 795b305d 3a202564 0a00              y[0]: %d..

$ objdump -s -j .data sections_demo | head -20

Contents of section .data:

 4020 00000000 00000000 64000000 00000000  ........d.......

 4030 8c438502 c9e53f00                    .C....?.

$ readelf -S sections_demo | grep bss

 [24] .bss              NOBITS           0000000000004038 002038 002000 00  WA  0   0 32

In the .rodata output, we can see our string literal “Hello, ELF sections!” as well as format strings like “%s” and “Magic: %d”. In the .data section, we can find our initialized values: 100 (0x64) and 3.14159 (stored in IEEE 754 floating-point format). And the .bss section is sized at 0x2000 bytes (8192 decimal), which covers our array and buffer.

Now let’s look at the .text section:

$ objdump -d -j .text sections_demo | grep -A10 "<initialize_buffer>:"

0000000000001169 <initialize_buffer>:

    1169:   55                      push   %rbp

    116a:   48 89 e5                mov    %rsp,%rbp

    116d:   48 83 ec 10             sub    $0x10,%rsp

    1171:   c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)

    1178:   eb 1e                   jmp    1198 <initialize_buffer+0x2f>

    117a:   8b 45 fc                mov    -0x4(%rbp),%eax

    117d:   8d 50 41                lea    0x41(%rax),%edx

    1180:   8b 45 fc                mov    -0x4(%rbp),%eax

    1183:   48 98                   cltq

    1185:   48 05 c0 60 00 00       add    $0x60c0,%rax

Here we can see the disassembly of our initialize_buffer function, which is part of the .text section.

Beyond the Basics: Other Important Sections

While we’ve covered the four main sections, ELF files typically contain many other sections for different purposes:

Dynamic Linking Sections

.plt (Procedure Linkage Table): Contains “trampolines” for calling external functions

.got (Global Offset Table): Contains addresses of dynamic symbols

.dynamic: Contains information needed by the dynamic linker

These sections are crucial for dynamic linking and will be covered in more detail in later chapters.

Symbol and Relocation Sections

.symtab: Contains symbol information for linking and debugging

.strtab: Contains strings referenced by symbol table entries

.rela.text: Contains relocations for the .text section

Initialization and Finalization

.init: Contains code executed before main()

.fini: Contains code executed after main() returns

.ctors and .dtors: Constructors and destructors for global C++ objects

We’ll dive deeper into these in the next chapter when we explore what happens before main().

Debugging Sections

.debug_*: Various sections containing DWARF debugging information

.eh_frame: Exception handling information

These sections help debuggers understand your program but aren’t needed for execution.

Practical Tips for Working with Sections

Now that we understand the theory, let’s look at some practical techniques for working with ELF sections.

Examining Section Sizes to Optimize Binaries

Use the size command to identify unusually large sections:

$ size --format=SysV sections_demo

sections_demo  :

section               size   addr

.interp                 28   4528

.note.gnu.property      64   4560

.note.gnu.build-id      36   4624

.note.ABI-tag           32   4660

.gnu.hash               36   4696

.dynsym                168   4736

.dynstr                143   4904

.gnu.version            14   5048

.gnu.version_r          48   5064

.rela.dyn              120   5112

.rela.plt               24   5232

.init                   27   8384

.plt                    16   8416

.plt.got                 8   8432

.text                 1533   8448

.fini                   13   9984

.rodata                104  12288

.eh_frame_hdr           52  12392

.eh_frame              176  12448

.init_array              8  16360

.fini_array              8  16368

.dynamic               240  16376

.got                     8  16616

.got.plt                32  16624

.data                   24  16672

.bss                  8192  16696

.comment                18      0

Total                11174

This detailed view helps identify where your binary’s size is going. In this example, we can see that the .bss section is quite large (8192 bytes), but remember this doesn’t affect the file size.

Stripping Unnecessary Sections

For production binaries, you can reduce file size by stripping unnecessary sections:

$ strip --strip-all sections_demo

$ ls -l sections_demo

-rwxr-xr-x 1 user user 14328 Jan 20 10:30 sections_demo

$ strip --strip-all sections_demo

$ ls -l sections_demo

-rwxr-xr-x 1 user user 10104 Jan 20 10:31 sections_demo

This removes debugging and symbol information, making the binary smaller but harder to debug.

Inspecting Section Permissions

Use readelf to verify section permissions:

$ readelf -S sections_demo | grep -E "text|rodata|data|bss" | awk '{print $3, $8}'

PROGBITS AX

PROGBITS A

PROGBITS WA

NOBITS WA

This confirms that our .text section is allocatable and executable, .rodata is allocatable but not writable or executable, and both .data and .bss are allocatable and writable.

Common Pitfalls and How to Avoid Them

Here are some common issues related to ELF sections and how to address them:

String Literals Modification Attempts

Attempting to modify string literals is a common mistake:

char *str = "Hello";

str[0] = 'h'; // SEGFAULT! String literals are in .rodata

Instead, use an array:

char str[] = "Hello"; // Creates a modifiable copy in the stack

str[0] = 'h'; // Works fine

Large .data Sections Bloating Binaries

Initializing large arrays with non-zero values can bloat your binary:

int huge_array[1000000] = {1}; // Only the first element is 1, rest are 0

Better approach:

int huge_array[1000000]; // In .bss, smaller binary

void init() {

    huge_array[0] = 1; // Runtime initialization

}

Accidentally Making Data Executable

Mixing code and data can lead to security vulnerabilities:

// DON'T DO THIS:

typedef struct {

    void (*func)(void);

    char buffer[1024];

} UnsafeStruct;

If the buffer overflows, it could corrupt the function pointer. Safer structure:

typedef struct {

    char buffer[1024];

} SafeData;

typedef struct {

    void (*func)(void);

    SafeData *data;

} SafeStruct;

This keeps code pointers separate from potentially overflowing buffers.

Controlling Section Placement with GCC Attributes

Now that you understand how sections work, you can use GCC attributes to control where your code and data are placed. This is useful for performance optimization, particularly for separating “hot” (frequently executed) code from “cold” (rarely executed) code.

Understanding Hot vs. Cold Code

The concept of “hot” and “cold” code comes from CPU cache optimization. Frequently executed code paths (hot code) benefit from being grouped together in memory, improving instruction cache hit rates. Rarely executed code (cold code) can be placed separately without impacting cache performance for the hot paths.

How to Use Section Attributes

You can place specific variables or functions in custom sections using the __attribute__((section("section_name"))) syntax:

// Place a frequently accessed function in a special "hot" section

__attribute__((section(".text.hot")))

void hot_function() {

    // This function is called frequently

    // Placing it in .text.hot helps keep it in cache

}

// Place a rarely accessed large data structure in a "cold" section

__attribute__((section(".data.cold")))

struct LargeStruct big_data;

Important Notes

The section names like .text.hot and .data.cold are conventions, not built-in sections. The linker will create these sections if you reference them.

You’ll need a custom linker script (covered in Chapter 8) to properly organize these custom sections for optimal performance.

This technique is most beneficial for performance-critical applications or embedded systems where cache behavior matters significantly.

Modern compilers with profile-guided optimization (PGO) can automatically identify and separate hot/cold code, so manual placement is often unnecessary.

When to Use This

Performance-critical applications where cache misses significantly impact performance

Embedded systems with limited instruction cache

When you have profiling data showing clear hot/cold code separation

When working with custom linker scripts for specific memory layouts

When NOT to Use This

General application development (let the compiler optimize)

When you don’t have profiling data to guide placement

If you’re not using a custom linker script to organize the sections

This gives you finer control over memory layout, which can be useful for performance optimization or memory-constrained environments, but should be used judiciously based on actual profiling data.

Security Implications of Section Layout

Understanding section permissions is crucial for security:

Code Injection Prevention: The separation of executable and writable sections helps prevent classic buffer overflow attacks.

Read-Only Relocation: Modern systems use “RELRO” (RELocation Read-Only) to make certain sections read-only after dynamic linking completes, preventing GOT overwrite attacks.

Stack Execution Prevention: While not an ELF section per se, the stack is typically marked as non-executable (NX) to prevent stack-based code injection.

You can check these protections with:

$ checksec --file=sections_demo

RELRO           STACK CANARY      NX            PIE             RPATH      RUNPATH      Symbols         FORTIFY      Fortified      Fortifiable  FILE

Partial RELRO   Canary found      NX enabled    PIE enabled     No RPATH   No RUNPATH   75 Symbols     Yes          0              3            sections_demo

How Sections Affect Program Loading and Performance

The organization of sections affects how efficiently your program loads and runs:

Locality: Related code and data placed close together can improve cache performance.

Paging: Sections are typically aligned to page boundaries, affecting how many pages get loaded into memory.

Copy-on-Write: Read-only sections can be shared between multiple instances of the same program, saving memory.

Cold vs. Hot Code: Rarely executed code can be placed in separate sections (like .text.hot and .text.cold) to improve cache utilization for hot code paths. See the “Controlling Section Placement with GCC Attributes” section for details on how to use GCC attributes to control this placement.

Tracing Real-World Examples

Let’s look at how section usage appears in real-world programs. We’ll examine the ls command:

$ size --format=SysV /bin/ls | sort -k2 -nr | head -10

section               size   addr

.text                64196   28672

.rodata              32272   94208

.data                 2608  131072

.bss                  2144  133680

.dynstr               1389    5720

.rela.plt             1296    7944

.dynsym               1080    4616

.rela.dyn              648    7296

.gnu.hash              400    3792

.eh_frame              340  126752

We can see that .text is the largest section (containing all the executable code), followed by .rodata (string literals and constants). The .data and .bss sections are relatively small. This distribution is typical for command-line utilities, which contain more code than data.

New Innovations in ELF Sections

While the basic section types we’ve discussed have remained stable for decades, there are ongoing innovations:

Split Sections: Modern compilers can split .text into hot and cold portions to improve instruction cache usage.

Link-Time Optimization (LTO): This technique allows the compiler to optimize across file boundaries, potentially rearranging section contents for better performance.

Control Flow Integrity (CFI): Some newer systems add special sections to store information that helps prevent control-flow hijacking attacks.

Memory Tagging: Experimental systems use special sections to store metadata for memory safety checking.

These innovations show that even though ELF is a mature format, it continues to evolve to address new security and performance challenges.

Summary

This chapter explored the internal organization of ELF executables, revealing how your C code gets carefully sorted into distinct sections with specific purposes. Here are the key points:

The Four Primary Sections

.text: Contains executable code, marked as executable but not writable

.rodata: Holds constants and string literals, marked as read-only

.data: Contains initialized global and static variables, writable

.bss: Reserved for uninitialized global and static variables, initialized to zero at runtime, and doesn’t take up file space

Critical Concepts

Section permissions enforce the “W^X” (Write XOR Execute) principle—memory can be either writable or executable, but not both. This is a critical security feature that prevents code injection attacks.

The .bss section is a clever optimization that avoids storing zeros in the executable file, keeping binaries compact.

Understanding section layout helps with debugging memory issues, optimizing binary size, and building secure software.

Practical Tools

readelf, objdump, and size help you inspect and understand the sections in your binaries.

You can use GCC attributes to control section placement for performance optimization (hot vs. cold code).

Stripping unnecessary sections reduces binary size for production deployments.

Understanding ELF sections gives you a deeper appreciation for how your code transforms from source to an executable program. It’s like knowing not just how to drive a car, but also understanding what’s happening under the hood.

In the next chapter, “Before main(): The Secret Life of Global Variables in C,” we’ll build on this foundation to explore what happens before your program’s main() function begins execution. We’ll see how global variables are set up, constructors are called, and the runtime environment is prepared for your program. This will give you even more insight into the fascinating journey your code takes from compilation to execution.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_4

4. Before main(): The Secret Life of Global Variables in C

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

Introduction

Most C programmers believe their program starts executing at main(), but this assumption misses a fascinating and complex initialization process that happens beforehand. Before your first line of main() runs, the system has already performed substantial work to prepare your program’s environment. Global variables have been initialized, memory has been allocated, and various startup routines have executed to create the foundation your program needs.

This hidden startup sequence is particularly important when working with ELF executables on Linux. The ELF format includes special sections and mechanisms specifically designed to handle program initialization. Understanding this process helps you write more reliable code, debug initialization problems, and optimize program startup performance.

We will explore how global variables get their initial values, examine the startup sequence that occurs before main(), and learn about the ELF sections that make this initialization possible. You will see how the compiler, linker, and runtime loader work together to ensure your global variables are ready when your program begins execution.

The Mystery of Global Variable Initialization

Let’s start with a simple example that demonstrates how global variables work:

// globals.c

#include <stdio.h>

// Global variables with various initializations

int simple_global = 42;

int array_global[] = {1, 2, 3, 4, 5};

char *string_global = "Hello, World!";

// Global struct with initializer

struct Point {

    int x;

    int y;

} point_global = {10, 20};

int main() {

    printf("simple_global: %d\n", simple_global);

    printf("array_global[2]: %d\n", array_global[2]);

    printf("string_global: %s\n", string_global);

    printf("point_global: (%d,%d)\n", point_global.x, point_global.y);

    return 0;

}

When you compile and run this program, all the global variables already have their correct values when main() begins. But how did this happen? The answer lies in the ELF initialization process.

Using GDB to Examine Pre-main Initialization

We can use GDB to peek behind the curtain and see what happens before main() executes:

$ gcc -g -o globals globals.c

$ gdb ./globals

 (gdb) break main

Breakpoint 1 at 0x1149: file globals.c, line 15.

(gdb) run

Starting program: ./globals

Breakpoint 1, main () at globals.c:15

(gdb) print simple_global

$1 = 42

(gdb) print array_global

$2 = {1, 2, 3, 4, 5}

(gdb) print point_global

$3 = {x = 10, y = 20}

All our global variables are already initialized when we hit main(). This reveals that initialization happens earlier in the program startup sequence.

The Hidden Startup Sequence

To understand when initialization occurs, we need to examine the complete startup sequence. Let’s look at what happens before main():

(gdb) break _start

(gdb) run

 Starting program: ./globals

Breakpoint 1, _start () at ../sysdeps/x86_64/start.S:120

(gdb) bt

#0  _start () at ../sysdeps/x86_64/start.S:120

The actual sequence that occurs is

1.

_start (assembly entry point from the ELF file)

2.

__libc_start_main (C runtime initialization)

3.

Initialization functions (global variable setup)

4.

main() (your program’s entry point)

We can observe this by setting breakpoints and stepping through the process:

(gdb) break __libc_start_main

(gdb) continue

 Continuing.

Breakpoint 2, __libc_start_main () at ../csu/libc-start.c:332

(gdb) info functions __do_global_*

All functions matching regular expression "__do_global_*":

Non-debugging symbols:

0x0000000000001090  __do_global_dtors_aux

0x00000000000010f0  __do_global_ctors_aux

The ELF Initialization Sections

The ELF format includes special sections that handle initialization. The most important of these is the .init_array section, which contains pointers to initialization functions:

$ readelf -W -d globals | grep INIT

 0x000000000000001b (INIT)               0x1000

 0x0000000000000019 (INIT_ARRAY)         0x3dc8

 0x000000000000001a (INIT_ARRAYSZ)       0x8

These initialization functions are automatically generated by the compiler and linker. The .init_array section contains function pointers that get called in order during program startup.

Types of Global Variable Initialization

Global variables can be initialized in several different ways, each handled by different mechanisms:

Constant Initialization

Simple constant values are computed at compile time and stored directly in the executable:

int simple = 42;                  // Stored in .data section

const int constant = 100;         // Stored in .rodata section

Zero Initialization

Variables initialized to zero are optimized to use the .bss section:

int zero_global = 0;             // Optimized to .bss

int implicit_zero_global;        // Automatically in .bss

Dynamic Initialization

Variables that require runtime computation need special handling:

// complex_globals.c

#include <stdio.h>

#include <stdlib.h>

// A global that requires computation

int computed_global = 40 + 2;

// A global that requires function calls

char *allocated_string = NULL;

// Constructor function that runs before main

__attribute__((constructor))

void init_globals() {

    allocated_string = strdup("Dynamically allocated string");

    printf("Constructor running!\n");

}

int main() {

    printf("In main(): computed_global = %d\n", computed_global);

    printf("In main(): allocated_string = %s\n", allocated_string);

    free(allocated_string);

    return 0;

}

When we compile and run this under GDB:

$ gcc -g -o complex_globals complex_globals.c

$ gdb ./complex_globals

 (gdb) break main

(gdb) run

Constructor running!

Breakpoint 1, main () at complex_globals.c:18

The constructor function runs before we hit main(), demonstrating the initialization sequence in action.

Global Variable Storage in ELF Sections

Let’s examine where these initialized globals are stored in the executable:

$ objdump -s -j .data complex_globals

 complex_globals:     file format elf64-x86-64

Contents of section .data:

 4000 2a000000 00000000 00000000          *...........

The computed value (42) is already stored in the .data section because the compiler evaluated the constant expression at compile time. This demonstrates how the compiler optimizes simple computations into static data.

Constructor and Destructor Functions

C provides attributes that let you specify functions to run before and after main():

// lifecycle.c

#include <stdio.h>

__attribute__((constructor))

void before_main() {

    printf("Constructor: Running before main\n");

}

__attribute__((destructor))

void after_main() {

    printf("Destructor: Running after main\n");

}

int main() {

    printf("Main: Now executing\n");

    return 0;

}

Running this shows the execution order:

$ ./lifecycle

 Constructor: Running before main

Main: Now executing

Destructor: Running after main

These constructor and destructor functions are stored in the .init_array and .fini_array sections, respectively.

Common Pitfalls and How to Avoid Them

Initialization Order Dependencies

One of the most dangerous pitfalls is relying on initialization order between global variables:

// Dangerous: relies on initialization order

int a = 42;

int b = a + 1;  // May not work as expected across translation units

The order of initialization is only guaranteed within a single translation unit (source file). Across different files, the order is undefined.

How to Avoid This Pitfall

Solution 1: Use Compile-Time Constants When Possible

// Good: compile-time evaluation, no runtime dependency

const int CONSTANT_A = 42;

const int CONSTANT_B = CONSTANT_A + 1;  // Always safe - compile-time

Solution 2: Use Lazy Initialization Functions

// Good: initialize on first use, avoiding order dependencies

static int computed_value = 0;

static int initialized = 0;

int get_computed_value(void) {

    if (!initialized) {

        extern int a;  // Reference to a (initialized before first call)

        computed_value = a + 10;  // Safe: a is initialized before main()

        initialized = 1;

    }

    return computed_value;

}

Solution 3: Use Constructors with Explicit Initialization

// Good: use constructor to set dependent values

int a = 42;

int b = 0;  // Initialize to 0, set properly in constructor

__attribute__((constructor))

void init_b() {

    b = a + 1;  // Safe: a is already initialized before constructor runs

}

Key Takeaway: Never rely on initialization order across files. Use compile-time constants, lazy initialization, or constructors to ensure proper ordering.

Dynamic Allocation Without Cleanup

Another common problem is allocating memory during initialization without proper cleanup:

// Dangerous: memory leak if program terminates abnormally

char *global_buf = NULL;

__attribute__((constructor))

void init_buf() {

    global_buf = malloc(1024);

    // No corresponding destructor to free this memory

}

How to Avoid This Pitfall

Solution: Always Pair Constructors with Destructors

// Good: proper cleanup with destructor

#include <stdio.h>

#include <stdlib.h>

char *global_buf = NULL;

__attribute__((constructor))

void init_buf() {

    global_buf = malloc(1024);

    if (global_buf) {

        printf("Constructor: Allocated 1024 bytes\n");

    }

}

__attribute__((destructor))

void cleanup_buf() {

    if (global_buf) {

        free(global_buf);

        global_buf = NULL;

        printf("Destructor: Freed memory\n");

    }

}

int main(void) {

    if (global_buf) {

        printf("Main: Using allocated buffer\n");

    }

    printf("Main: Exiting (destructor will clean up)\n");

    return 0;

}

Output

Constructor: Allocated 1024 bytes

Main: Using allocated buffer

Main: Exiting (destructor will clean up)

Destructor: Freed memory

Key Takeaway: Every __attribute__((constructor)) that allocates resources should have a corresponding __attribute__((destructor)) to clean them up. This ensures proper cleanup even if the program terminates abnormally.

Circular Dependencies

Circular dependencies between global variables create undefined behavior:

// Dangerous: undefined behavior

extern int b;

int a = b + 1;

int b = a + 1;

How to Avoid This Pitfall

Solution: Break the Circular Dependency

// Good: remove circular dependency

// Option 1: Use a single initialization point

int a = 0;

int b = 0;

__attribute__((constructor))

void init_globals() {

    a = 42;  // Set a first

    b = a + 1;  // Then set b based on a

}

// Option 2: Use lazy initialization

static int a_initialized = 0;

static int b_initialized = 0;

static int a_val = 0;

static int b_val = 0;

int get_a(void) {

    if (!a_initialized) {

        a_val = 42;

        a_initialized = 1;

    }

    return a_val;

}

int get_b(void) {

    if (!b_initialized) {

        b_val = get_a() + 1;  // Safe: get_a() initializes a first

        b_initialized = 1;

    }

    return b_val;

}

Key Takeaway

Never create circular dependencies between global variable initializers. Use constructors or lazy initialization to establish a clear initialization order.

Best Practices for Global Variable Management

Prefer Static Initialization

Use compile-time constants whenever possible:

// Good: compile-time constant

static const int CONFIG_VALUE = 100;

// Avoid: runtime computation

static int config_value = compute_value();

Use Singleton Functions for Complex Initialization

Instead of raw global variables, consider using functions that initialize on first use:

// Better than raw global variables

int* get_config(void) {

    static int config = 0;

    static int initialized = 0;

    if (!initialized) {

        config = compute_value();

        initialized = 1;

    }

    return &config;

}

Implement Guards for Dynamic Initialization

When you must use dynamic initialization, implement proper guards:

static int initialized = 0;

static char* global_resource = NULL;

void init_resource(void) {

    if (!initialized) {

        global_resource = allocate_resource();

        initialized = 1;

    }

}

Debugging Global Initialization Issues

Here are useful GDB commands for debugging global initialization problems:

# Break before any initialization

(gdb) break _start

# Watch a global variable for changes

(gdb) watch global_variable

# Show constructor functions

(gdb) info functions __attribute__

# Step through initialization

(gdb) step

You can also use the readelf command to examine the initialization sections:

$ readelf -S executable_name | grep -E "init|fini"

This shows you the .init_array and .fini_array sections that contain the initialization and cleanup functions.

Performance Considerations

Global initialization can impact program startup time, especially with complex constructors. Here are some optimization strategies:

Minimize Constructor Work

Keep constructor functions lightweight and defer expensive operations:

__attribute__((constructor))

void quick_init() {

    // Only do essential initialization here

    setup_critical_data();

    // Defer expensive operations to first use

    expensive_initialization_needed = 1;

}

Use Lazy Initialization

For resources that might not be used, consider lazy initialization:

static FILE* log_file = NULL;

FILE* get_log_file(void) {

    if (!log_file) {

        log_file = fopen("application.log", "a");

    }

    return log_file;

}

Security Implications

Global initialization can have security implications, particularly in programs that change privilege levels.

Avoid Sensitive Operations in Constructors

Don’t perform security-sensitive operations in global constructors, as they run with the program’s initial privileges:

// Dangerous: runs with elevated privileges

__attribute__((constructor))

void dangerous_init() {

    // This might run as root before dropping privileges

    system("some_command");

}

Initialize Security-Critical Data Carefully

Ensure that security-critical global data is properly initialized:

// Good: explicit initialization of security data

static char secure_buffer[1024];

__attribute__((constructor))

void init_security() {

    memset(secure_buffer, 0, sizeof(secure_buffer));

    // Additional security setup

}

Summary

This chapter revealed the complex initialization process that occurs before main() begins execution. Here are the key points:

The Initialization Sequence

Program execution begins before main() with a complex initialization sequence managed by the ELF format.

The sequence is _start ➤ __libc_start_main ➤ initialization functions ➤ main().

The .init_array and .fini_array sections contain pointers to constructor and destructor functions.

Types of Initialization

Constant Initialization: Computed at compile time, stored in .data or .rodata sections

Zero Initialization: Optimized to use the .bss section (doesn’t take file space)

Dynamic Initialization: Requires runtime functions (constructors) that execute before main()

Critical Concepts

Initialization order is only guaranteed within a single translation unit, not across different source files.

Static initialization is preferred over dynamic initialization for performance and reliability.

Constructor and destructor functions provide hooks for custom initialization and cleanup code.

Every __attribute__((constructor)) that allocates resources should have a corresponding __attribute__((destructor)).

Best Practices

Prefer compile-time constants whenever possible.

Use singleton functions for complex initialization.

Implement guards for dynamic initialization.

Never rely on initialization order across files.

Always pair constructors with destructors for resource cleanup.

Understanding the initialization process is crucial for debugging startup issues and writing secure, efficient programs. The key insight is that program startup involves multiple phases, each handled by different components of the system. Static initialization happens at compile time and gets embedded in the executable, while dynamic initialization requires runtime functions that execute before main().

In the next chapter, “The Linker: Bringing Your C Code Together,” we’ll explore how the linker handles global variables when combining multiple object files. You’ll learn how symbol resolution works, how the linker manages initialization ordering across translation units, and how to control the linking process for optimal results.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_5

5. The Linker: Bringing Your C Code Together

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

Introduction

When you build anything more complex than a simple “Hello, World!” program, you typically split your C code across multiple files. This modular approach makes code easier to maintain, test, and reuse. However, it creates a challenge: How do you combine all these separate pieces into a single working executable? The answer lies in the linker, a sophisticated tool that performs the crucial task of bringing your code together.

The linker operates on ELF object files, which are the compiled but not yet executable versions of your source code. Understanding how the linker works is essential for building robust C programs on Linux. You need to know how symbol resolution works, how the linker handles relocations, and how to troubleshoot common linking problems.

This chapter will explore the linking process in detail, from basic symbol resolution to advanced techniques like Link-Time Optimization and symbol versioning. You will learn how to work with static libraries, control symbol visibility, and debug linking issues. By the end, you will have a solid understanding of how the linker transforms your collection of object files into a functioning executable.

A Multi-file Project Example

Let’s start with a practical example that demonstrates the linking process. We’ll create a simple calculator library that we’ll use throughout this chapter:

// mylib.h

#ifndef MYLIB_H

#define MYLIB_H

// Function declarations

int add(int a, int b);

int subtract(int a, int b);

int multiply(int a, int b);

int divide(int a, int b);

// Error handling

extern int last_error;

#define ERROR_DIVIDE_BY_ZERO 1

#endif

// mylib.c

#include "mylib.h"

int last_error = 0;

int add(int a, int b) {

    return a + b;

}

int subtract(int a, int b) {

    return a - b;

}

int multiply(int a, int b) {

    return a * b;

}

int divide(int a, int b) {

    if (b == 0) {

        last_error = ERROR_DIVIDE_BY_ZERO;

        return 0;

    }

    return a / b;

}

// main.c

#include <stdio.h>

#include "mylib.h"

int main() {

    printf("10 + 5 = %d\n", add(10, 5));

    printf("10 - 5 = %d\n", subtract(10, 5));

    printf("10 * 5 = %d\n", multiply(10, 5));

    int result = divide(10, 0);

    if (last_error == ERROR_DIVIDE_BY_ZERO) {

        printf("Error: Division by zero!\n");

    }

    return 0;

}

This example demonstrates several key concepts: function declarations in headers, external variables, and cross-file dependencies that the linker must resolve.

The Compilation Process

Before the linker can do its work, we need to compile our source files into object files. Let’s compile these files separately and examine what happens:

$ gcc -c mylib.c -o mylib.o

$ gcc -c main.c -o main.o

The -c flag tells GCC to compile but not link. This produces ELF object files that contain compiled code but are not yet executable. Let’s examine these object files:

$ file mylib.o main.o

mylib.o: ELF 64-bit relocatable, x86-64, version 1 (SYSV), not stripped

main.o:  ELF 64-bit relocatable, x86-64, version 1 (SYSV), not stripped

Notice they’re marked as “relocatable” rather than “executable”. This indicates they contain code that needs to be linked with other object files before it can run.

Understanding Object Files and Symbols

Object files contain symbols that represent functions, variables, and other program elements. Let’s examine the symbols in our object files:

$ nm mylib.o

0000000000000000 T add

0000000000000000 D last_error

0000000000000020 T multiply

0000000000000010 T subtract

0000000000000030 T divide

$ nm main.o

                 U add

                 U divide

                 U last_error

0000000000000000 T main

                 U multiply

                 U printf

                 U subtract

The output shows different symbol types:

T: Defined text (code) symbols

U: Undefined symbols that need to be resolved by the linker

D: Defined data symbols

This reveals the fundamental linking problem: main.o has undefined references to functions that are defined in mylib.o. The linker’s job is to resolve these references.

The Linking Process

Now let’s link these objects together to create an executable:

$ gcc mylib.o main.o -o calculator

We can see what the linker accomplished by examining the final executable:

$ nm calculator

0000000000004010 D last_error

0000000000001129 T add

0000000000001139 T subtract

0000000000001149 T multiply

0000000000001159 T divide

0000000000001169 T main

Notice the changes:

All symbols now have final addresses.

There are no more undefined symbols.

The addresses are properly spaced to accommodate each function’s size.

Symbol Resolution in Detail

The linker performs symbol resolution by matching symbol definitions with their uses. Let’s examine this process more closely:

$ objdump -t main.o | grep add

0000000000000000         *UND*    0000000000000000 add

$ objdump -t mylib.o | grep add

0000000000000000 g     F .text    0000000000000010 add

This shows main.o has an undefined reference to add, which is defined in mylib.o. The linker matches these up during the linking process.

Relocation: Patching Addresses

Before linking, object files contain placeholder addresses that need to be updated with final addresses. Let’s examine the relocations needed in main.o:

$ objdump -r main.o

main.o:     file format elf64-x86-64

RELOCATION RECORDS FOR [.text]:

OFFSET           TYPE              VALUE

0000000000000019 R_X86_64_PLT32    add-0x0000000000000004

000000000000002f R_X86_64_PLT32    subtract-0x0000000000000004

0000000000000045 R_X86_64_PLT32    multiply-0x0000000000000004

000000000000005b R_X86_64_PLT32    divide-0x0000000000000004

RELOCATION RECORDS FOR [.data]:

OFFSET           TYPE              VALUE

These relocation records tell the linker exactly how to patch the code once final addresses are determined. Each record specifies an offset in the object file and the type of relocation needed.

Common Linking Issues and Solutions

Undefined References

One of the most common linking errors occurs when the linker cannot find a definition for a symbol. Let’s demonstrate this by removing the definition of multiply:

$ # Remove multiply from mylib.c and recompile

$ gcc -c mylib.c -o mylib.o

$ gcc mylib.o main.o -o calculator

/usr/bin/ld: main.o: in function `main':

main.c:(.text+0x45): undefined reference to `multiply'

collect2: error: ld returned 1 exit status

This error tells us exactly where the problem is: main.c uses multiply, but no definition was found.

Multiple Definitions

Another common issue occurs when the same symbol is defined in multiple object files:

// If we define last_error in both mylib.c and main.c

int last_error = 0;

$ gcc mylib.o main.o -o calculator

/usr/bin/ld: main.o:(.data+0x0): multiple definition of `last_error';

mylib.o:(.data+0x0): first defined here

collect2: error: ld returned 1 exit status

Link Order Dependencies

The order of object files and libraries can affect linking success:

# This works (libraries after their users)

$ gcc main.o mylib.o -o calculator

# This might fail with some linkers (libraries before their users)

$ gcc mylib.o main.o -o calculator

Working with Static Libraries

Static libraries provide a convenient way to package related object files together. Let’s create a static library from our calculator code:

$ ar rcs libcalc.a mylib.o

The ar command creates an archive file containing our object file. Now we can link against the library:

$ gcc main.o -L. -lcalc -o calculator

The -L. flag tells the linker to look in the current directory for libraries, and -lcalc tells it to link with libcalc.a.

Let’s examine the library:

$ ar t libcalc.a

mylib.o

$ nm libcalc.a

mylib.o:

0000000000000000 T add

0000000000000000 D last_error

0000000000000020 T multiply

0000000000000010 T subtract

0000000000000030 T divide

Understanding the Linker's Verbose Output

We can see exactly what the linker is doing by using verbose output:

$ gcc -Wl,--verbose main.o -L. -lcalc -o calculator

GNU ld version ...

  attempt to open main.o succeeded

  attempt to open /usr/lib/gcc/...

  attempt to open libcalc.a succeeded

...

This output shows the linker’s search process and which files it successfully opens and processes.

Link-Time Optimization

Modern compilers support Link-Time Optimization (LTO), which allows optimization across object file boundaries:

$ gcc -flto -c mylib.c -o mylib.o

$ gcc -flto -c main.c -o main.o

$ gcc -flto mylib.o main.o -o calculator_lto

Compare the sizes:

$ ls -l calculator calculator_lto

-rwxr-xr-x 1 user user 16704 calculator

-rwxr-xr-x 1 user user 16168 calculator_lto

LTO can produce smaller, more efficient executables by optimizing across the entire program.

Controlling Symbol Visibility

We can control which symbols are visible outside our library using visibility attributes:

// In mylib.c

__attribute__((visibility("hidden")))

int internal_helper(int x) {

    return x * 2;

}

// This symbol will be visible

int multiply(int a, int b) {

    return internal_helper(a) * b / 2;

}

Check the symbols:

$ gcc -fvisibility=hidden -c mylib.c -o mylib.o

$ nm mylib.o | grep internal_helper

0000000000000000 t internal_helper

Note the lowercase “t” indicating a local symbol that won’t be visible outside the object file.

Advanced Linking Techniques

Version Scripts

Version scripts allow fine-grained control over symbol visibility and versioning:

CALC_1.0 {

    global:

        add;

        subtract;

        multiply;

        divide;

        last_error;

    local:

        *;

};

Link with the version script:

$ gcc -Wl,--version-script=calc.map main.o mylib.o -o calculator

Weak Symbols

Weak symbols provide default implementations that can be overridden:

// Provide a default that can be overridden

__attribute__((weak))

int custom_multiply(int a, int b) {

    return a * b;

}

Common Symbols

Common symbols handle cases where the same variable might be defined in multiple files:

// In header

extern int config_value;

// In multiple source files

int config_value = 42;  // Common symbol

Debugging Link Issues

Here are essential commands for debugging linking problems:

# Show undefined symbols

$ nm -u calculator

# Show all symbols with demangled names

$ nm -C calculator

# Show dependencies

$ ldd calculator

# Show section sizes

$ size calculator

# Examine relocations

$ objdump -r object_file.o

# Show library search paths

$ gcc -print-search-dirs

Best Practices for Linking

Use Header Guards

Always protect headers against multiple inclusion:

#ifndef MYLIB_H

#define MYLIB_H

// ... header contents ...

#endif

Minimize Global Variables

Instead of exposing global variables directly, use accessor functions:

// Better: use accessor functions

int get_last_error(void);

void set_last_error(int error);

Use Static for Internal Functions

Mark internal functions as static to prevent them from being exported:

static int internal_helper(int x) {

    // ... implementation ...

}

Organize Libraries Logically

Group related functionality into libraries and minimize dependencies between them.

Performance Considerations

Link Order Optimization

Place frequently used libraries later in the link order to improve startup time.

Dead Code Elimination

Use compiler flags to eliminate unused code:

$ gcc -ffunction-sections -fdata-sections -Wl,--gc-sections

Profile-Guided Optimization

Combine LTO with profile-guided optimization for maximum performance:

$ gcc -fprofile-generate -flto ...

$ ./program  # Run with representative input

$ gcc -fprofile-use -flto ...

Summary

This chapter explored the crucial role of the linker in transforming separate object files into working executables. Here are the key points:

The Linking Process

The linker combines multiple object files into a single executable by resolving symbols and handling relocations.

Object files contain defined symbols (functions and variables) and undefined references that need resolution.

Symbol resolution matches definitions with uses across different object files.

Relocation records tell the linker how to patch addresses once the final memory layout is determined.

ELF Format Foundation

The ELF format provides the foundation for linking, with its standardized symbol tables and relocation records.

Understanding the linking process helps you write better modular code and debug complex build issues.

Practical Techniques

Static libraries provide a convenient way to package related object files for reuse.

Link-Time Optimization can improve performance by optimizing across object file boundaries.

Symbol visibility control helps create cleaner library interfaces and smaller executables.

Advanced techniques like version scripts and weak symbols provide fine-grained control over the linking process.

Common Problems and Solutions

Common linking errors include undefined references, multiple definitions, and link order dependencies.

These issues often arise in larger projects, and knowing how to diagnose and fix them is essential for productive C development.

Key Insight: The key insight is that linking is not just about combining files but about creating a coherent program where all the pieces work together correctly. Understanding the linking process is essential for debugging build issues and writing maintainable modular code.

In the next chapter, “Relocations: How the Linker Patches Your Code,” we’ll examine the relocation process in much greater detail. You’ll learn about different types of relocations, how they work at the assembly level, and how modern techniques like position-independent code affect the linking process.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_6

6. Relocations: How the Linker Patches Your Code

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

Introduction

When you compile C code into object files, the compiler faces a fundamental problem: it doesn’t know the final addresses where functions and variables will reside in the finished executable. The compiler generates machine code that needs to reference these symbols, but their final locations won’t be determined until the linking phase. The solution is relocations, which are essentially placeholders that tell the linker exactly how to patch the code once final addresses are known.

Relocations are a cornerstone of the ELF format and the linking process. They enable modular programming by allowing code to be compiled separately and then combined later. Understanding how relocations work is crucial for debugging linking errors, writing position-independent code, and creating efficient shared libraries.

This chapter will explore the different types of relocations, how they are applied during linking, and their performance implications. You will learn to identify relocation issues, understand the trade-offs between different relocation types, and apply best practices for writing code that links efficiently. By the end, you will have a solid grasp of how the linker transforms placeholder addresses into working executable code.

Understanding Relocations with a Practical Example

Let’s start with a comprehensive example that demonstrates various types of relocations:

// message.h

#ifndef MESSAGE_H

#define MESSAGE_H

extern const char* get_message(void);

extern void print_message(void);

extern int message_count;

#endif

// message.c

#include <stdio.h>

#include "message.h"

const char* messages[] = {

    "Hello, World!",

    "How are you?",

    "Goodbye!"

};

int message_count = 3;

const char* get_message(void) {

    static int index = 0;

    if (index >= message_count) {

        index = 0;

    }

    return messages[index++];

}

void print_message(void) {

    printf("%s\n", get_message());

}

// main.c

#include <stdio.h>

#include "message.h"

int main() {

    printf("There are %d messages:\n", message_count);

    for (int i = 0; i < message_count; i++) {

        print_message();

    }

    return 0;

}

This example creates cross-file dependencies that require different types of relocations to resolve.

Examining Relocations in Object Files

Let’s compile these files and examine the relocations they contain:

$ gcc -c message.c -o message.o

$ gcc -c main.c -o main.o

Now let’s look at the relocations in main.o:

$ objdump -r main.o

main.o:     file format elf64-x86-64

RELOCATION RECORDS FOR [.text]:

OFFSET           TYPE              VALUE

0000000000000015 R_X86_64_32S     message_count

000000000000002f R_X86_64_PLT32   print_message-0x4

0000000000000041 R_X86_64_PC32    message_count-0x4

RELOCATION RECORDS FOR [.rela.text]:

OFFSET           TYPE              VALUE

The output shows three different relocation types, each serving a specific purpose in the linking process.

Common Relocation Types

Absolute Relocations (R_X86_64_32S)

These relocations represent direct references to absolute addresses:

// This code generates an absolute relocation

extern int global_var;

int value = global_var;  // Direct memory access

PC-Relative Relocations (R_X86_64_PC32)

These relocations calculate addresses relative to the current instruction pointer:

// This code generates a PC-relative relocation

extern int data_array[];

int get_value(int index) {

    return data_array[index];  // Address calculated relative to current position

}

PLT Relocations (R_X86_64_PLT32)

These relocations handle function calls through the Procedure Linkage Table:

// This code generates a PLT relocation

extern void external_function(void);

void caller(void) {

    external_function();  // Function call via PLT

}

Detailed Relocation Analysis

Let’s create a more focused example to understand each relocation type:

// relocations.c

extern int global_var;         // Will need absolute relocation

extern void far_func(void);    // Will need PLT relocation

extern int array[];            // Will need PC-relative relocation

int use_relocations(void) {

    int result = global_var;           // R_X86_64_32S

    far_func();                        // R_X86_64_PLT32

    return result + array[global_var]; // R_X86_64_PC32

}

Now compile and link everything together:

$ gcc -o program relocations.c definitions.c

$ ./program

Output

global_var = 42

array[0] = 1

far_func called

use_relocations() returned: 42

We can see the final addresses using nm:

$ nm program | grep -E 'global_var|array|far_func|use_relocations|main'

Output (Ubuntu 22.04)

0000000000004020 D array

00000000000011a4 T far_func

0000000000004010 D global_var

00000000000011be T main

0000000000001169 T use_relocations

Output (Fedora 43)

0000000000403030 D array

00000000004004a2 T far_func

0000000000403020 D global_var

00000000004004b3 T main

0000000000400476 T use_relocations

Note

The exact addresses differ between distributions due to different default memory layouts and linker configurations, but the important point is that all relocations have been resolved to actual addresses.

Notice how the linker has resolved all the relocations:

global_var is at address 0x4010 (data section).

array is at address 0x4020 (data section).

far_func is at address 0x11a4 (text section).

use_relocations is at address 0x1169 (text section).

This disassembly shows the exact locations where relocations occur and how they appear in the machine code.

How the Linker Applies Relocations

Let’s see how the linker patches these relocations. First, we’ll create definitions for our external symbols:

// definitions.c

#include <stdio.h>

int global_var = 42;

int array[] = {1, 2, 3, 4, 5};

void far_func(void) {

printf("far_func called\n");

}

// Main function to create a complete executable

int main(void) {

extern int use_relocations(void);

printf("global_var = %d\n", global_var);

printf("array[0] = %d\n", array[0]);

int result = use_relocations();

printf("use_relocations() returned: %d\n", result);

return 0;

}

Now compile and link everything together:

$ gcc -o program relocations.c definitions.c

We can see the final addresses using nm:

$ nm program | grep -E 'global_var|array|far_func'

0000000000004020 D global_var

0000000000004040 D array

0000000000001129 T far_func

Relocation Calculation Methods

Absolute Relocations

For absolute relocations, the linker simply replaces the placeholder with the actual address:

// Before linking:

mov    $0x0,%eax    // Placeholder value

// After linking:

mov    $0x4020,%eax // Actual address of global_var

PC-Relative Relocations

For PC-relative relocations, the linker calculates the offset from the current instruction to the target:

// Before linking:

lea    0x0(%rip),%rax    // Placeholder offset

// After linking:

lea    -0x2e(%rip),%rax  // Calculated offset to target

PLT Relocations

For PLT relocations, the linker calculates the offset to the PLT entry:

// Before linking:

call   0x0                // Placeholder offset

// After linking:

call   0x1020             // Offset to PLT entry

Debugging Relocation Issues

Undefined References

One of the most common relocation problems is undefined references:

$ gcc -c main.c -o main.o

$ gcc main.o -o program

/usr/bin/ld: main.o: in function `main':

main.c:(.text+0x15): undefined reference to `message_count'

Debug this by examining symbol tables:

$ nm main.o | grep ' U '     # List undefined symbols

$ nm message.o | grep ' D '  # List defined data symbols

Position-Independent Code Issues

When creating shared libraries, you need position-independent code:

$ gcc -c -fPIC message.c -o message.o

$ objdump -r message.o # Notice different relocation types

Text Relocations

Text relocations occur when absolute addresses are used in code sections:

$ gcc -shared -o libmessage.so message.o

/usr/bin/ld: message.o: relocation R_X86_64_32S against `.data' can not be used when making a shared object; recompile with -fPIC

Advanced Relocation Types

Copy Relocations

Copy relocations occur when a shared library exports a data symbol that is used by the main program:

// In shared library

extern int shared_data;

// In main program

int shared_data;  // Will trigger copy relocation

GOT Relocations

Global Offset Table entries are used for position-independent code:

extern int global_var;

int get_global(void) {

    return global_var;  // Will use GOT in PIC

}

IFUNC Relocations

GNU indirect functions enable runtime symbol resolution:

#include <stdlib.h>

typedef int (*func_type)(int);

static int slow_impl(int x) { return x * x; }

static int fast_impl(int x) { return x << 1; }

static func_type resolve_func(void) {

    if (getenv("FAST_MODE"))

        return fast_impl;

    return slow_impl;

}

__attribute__((ifunc("resolve_func")))

int compute(int x);

Performance Implications of Relocations

Absolute Relocations

Absolute relocations provide the fastest runtime performance but require load-time fixups:

Fastest execution speed

Requires memory pages to be writable during loading

Not suitable for shared libraries

PC-Relative Relocations

PC-relative relocations offer good performance with position independence:

Good runtime performance

Position-independent code

Smaller code size

No load-time fixups needed

PLT Relocations

PLT relocations add indirection but enable lazy binding:

Additional indirection overhead

Enables lazy symbol resolution

Slightly slower than direct calls

Essential for shared libraries

Security Considerations

RELRO (RELocation Read-Only)

Modern systems use RELRO to make relocation sections read-only after loading:

$ gcc -Wl,-z,relro,-z,now -o program source.c

Address Space Layout Randomization (ASLR)

ASLR works with position-independent code to randomize memory layout:

$ gcc -fPIC -pie -o program source.c

Best Practices for Relocations

Use Position-Independent Code When Appropriate

For shared libraries, always use -fPIC:

$ gcc -fPIC -c library.c # For shared libraries

Minimize Global Variables

Prefer local constants over global ones:

// Prefer:

static const int LOCAL_CONSTANT = 42;

// Over:

const int GLOBAL_CONSTANT = 42;

Group-Related Symbols

Group-related data for better locality:

// Better locality:

struct Config {

    int value1;

    int value2;

} config;

// Instead of:

int config_value1;

int config_value2;

Tools for Analyzing Relocations

Essential Commands

# Show relocations in object files

$ objdump -r object_file.o

# Show relocations with disassembly

$ objdump -dr object_file.o

# Show dynamic relocations

$ readelf -r executable

# Show PLT entries

$ objdump -d -j .plt executable

Advanced Analysis

# Show relocation statistics

$ readelf -W -r executable | grep -c R_X86_64

# Check for text relocations

$ readelf -d executable | grep TEXTREL

# Analyze GOT usage

$ objdump -s -j .got executable

Optimization Techniques

Link-Time Optimization

LTO can reduce relocation overhead:

$ gcc -flto -O2 -c source.c

$ gcc -flto -O2 -o program *.o

Profile-Guided Optimization

PGO can optimize relocation patterns:

$ gcc -fprofile-generate -o program source.c

$ ./program  # Run with representative data

$ gcc -fprofile-use -o program source.c

Troubleshooting Common Issues

Relocation Truncation

Large programs may exceed relocation limits:

# Use large code model for huge programs

$ gcc -mcmodel=large -o program source.c

Mixed PIC and Non-PIC Code

Ensure consistency in compilation flags:

# All object files should use same PIC setting

$ gcc -fPIC -c *.c

$ gcc -shared -o library.so *.o

Summary

This chapter explored the critical role of relocations in the linking process. Here are the key points:

What Are Relocations

Relocations are placeholders that tell the linker how to patch addresses in object files.

They are the mechanism that allows the linker to transform object files with placeholder addresses into executable programs with correct memory references.

The linker applies relocations by calculating final addresses and patching the machine code.

Types of Relocations

Different relocation types serve different purposes: absolute for direct access, PC-relative for position independence, and PLT for function calls.

We examined the major relocation types, from simple absolute relocations to complex PLT and GOT relocations used in position-independent code.

Position-Independent Code

Position-independent code requires more complex relocations but enables shared libraries and security features.

Modern security features like RELRO and ASLR depend on proper relocation handling.

The key insight is that relocations represent a trade-off between flexibility and performance.

Practical Considerations

Common relocation issues include undefined references, text relocations, and PIC/non-PIC mixing.

Performance implications vary by relocation type, with absolute being fastest and PLT having indirection overhead.

Best practices include using PIC for shared libraries, minimizing global variables, and grouping related symbols.

Why Understanding Relocations Matters: Understanding relocations is essential for several reasons:

It helps you debug linking errors by understanding what the linker is trying to accomplish.

It enables you to write more efficient code by choosing the right compilation options for your use case.

It helps you understand the performance implications of different coding patterns.

Debugging tools like objdump and readelf help analyze relocation patterns and issues.

Understanding relocations is crucial for debugging linking errors and optimizing program performance.

In the next chapter, “Symbols: The Linker's Address Book,” we’ll explore how symbols work in conjunction with relocations to create the final executable. You’ll learn about symbol tables, symbol resolution, and how the linker manages the namespace of your program.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_7

7. Symbols: The Linker’s Address Book

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

You’ve written a perfectly working function in one file and need to call it from another. It seems simple enough, just declare it extern and call it. But have you ever wondered how the linker actually finds your function among potentially thousands of others spread across dozens of object files? Or why sometimes you get “multiple definition” errors while other times the linker silently picks one version? The answer lies in symbols—the linker’s sophisticated addressing system that makes modular programming possible.

The Symbol Mystery: What’s Really in Your Object Files?

Let’s start with a program that demonstrates the full spectrum of symbols:

// symbol_showcase.c

#include <stdio.h>

// Global variable with initialization (strong symbol, .data section)

int user_count = 42;

// Global variable without initialization (common symbol, .bss section)

int connection_pool[1000];

// Static variable (local symbol, not visible outside)

static double pi_approximation = 3.14159;

// Constant data (goes to .rodata)

const char *service_name = "AuthService v2.0";

// Regular function (global symbol)

void log_message(const char *msg) {

    printf("[%s] %s\n", service_name, msg);

}

// Static function (local symbol)

static int calculate_internal_metric(void) {

    return user_count * 2;

}

// Weak function (can be overridden)

__attribute__((weak))

int get_default_timeout(void) {

    return 30;  // 30 seconds default

}

int main(void) {

    log_message("Service starting");

    printf("Timeout: %d seconds\n", get_default_timeout());

    printf("Internal metric: %d\n", calculate_internal_metric());

    return 0;

}

Let’s compile and examine what symbols are created:

$ gcc -c symbol_showcase.c -o symbol_showcase.o

$ readelf --symbols symbol_showcase.o

Here’s the symbol table output (showing key entries):

Symbol table “.symtab” contains 18 entries:

 Num: Value             Size Type    Bind   Vis      Ndx Name

 0:   0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND

 1:   0000000000000000     0 FILE    LOCAL  DEFAULT  ABS symbol_showcase.c

 2:   0000000000000000     0 SECTION LOCAL  DEFAULT  1 .text

 3:   0000000000000000     0 SECTION LOCAL  DEFAULT  3 .data

 4:   0000000000000000     0 SECTION LOCAL  DEFAULT  4 .bss

 5:   0000000000000000     0 SECTION LOCAL  DEFAULT  5 .rodata

 6:   0000000000000000     8 OBJECT  LOCAL  DEFAULT  3 pi_approximation

 7:   0000000000000025    29 FUNC    LOCAL  DEFAULT  1 calculate_internal_metric

 8:   0000000000000000     0 SECTION LOCAL  DEFAULT  7 .note.GNU-stack

 9:   0000000000000000     0 SECTION LOCAL  DEFAULT  8 .eh_frame

10:   0000000000000000  4000 OBJECT  GLOBAL DEFAULT  COM connection_pool

11:   0000000000000000     4 OBJECT  GLOBAL DEFAULT  3 user_count

12:   0000000000000000     8 OBJECT  GLOBAL DEFAULT  5 service_name

13:   0000000000000000    37 FUNC    GLOBAL DEFAULT  1 log_message

14:   0000000000000050    13 FUNC    WEAK   DEFAULT  1 get_default_timeout

15:   0000000000000060    75 FUNC    GLOBAL DEFAULT  1 main

16:   0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf

17:   0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _GLOBAL_OFFSET_TABLE_

Let’s decode this line by line to understand what each field means:

Num: Index in the symbol table (line 0-17)

Value: Offset within the section (0x0000000000000000 means start of section)

Size: How many bytes the symbol occupies

Type: FUNC (function), OBJECT (data), SECTION, FILE, or NOTYPE

Bind: LOCAL (static), GLOBAL (extern), WEAK, or COMMON

Vis: Visibility (DEFAULT means normal visibility)

Ndx: Section index (1=.text, 3=.data, 4=.bss, 5=.rodata, UND=undefined)

Name: The symbol name

Key Observations

Line 6: pi_approximation is LOCAL (static)—only visible in this file.

Line 11: user_count is GLOBAL in .data (initialized data).

Line 10: connection_pool is marked COM (common)—special handling for uninitialized globals.

Line 14: get_default_timeout is WEAK—can be overridden.

Line 16: printf is UND (undefined)—needs to be resolved from libc.

These fields encode crucial information about your program’s structure. The Bind field determines visibility: LOCAL symbols won’t cause linker errors if another file has the same name, while GLOBAL symbols must be unique across the link. The Ndx field tells us which section contains the symbol—we see .text (line 1) for code, .data (line 3) for initialized variables, and .bss (line 4) for uninitialized ones. UND indicates the symbol is declared but not defined here; the linker must find it elsewhere. This rich metadata is what enables the linker to detect conflicts, resolve dependencies, and produce either a valid executable or detailed error messages.

Understanding Symbol Types and Bindings

The Three Levels of Symbol Visibility

Let’s create a comprehensive example showing all binding types:

// visibility_demo.c

#include <stdio.h>

// 1. LOCAL binding - only visible within this compilation unit

static int local_counter = 0;

static void local_helper(void) {

    local_counter++;

}

// 2. GLOBAL binding - visible to all object files

int global_counter = 0;

void global_function(void) {

    global_counter++;

    local_helper();  // Can call local functions internally

}

// 3. WEAK binding - can be overridden

__attribute__((weak)) int weak_setting = 100;

__attribute__((weak)) void weak_function(void) {

    printf("Default implementation (weak)\n");

}

// 4. COMMON symbols (legacy C behavior)

int uninitialized_global;  // Becomes COMMON symbol

// Show all symbols

void show_symbols(void) {

    printf("Local counter: %d\n", local_counter);

    printf("Global counter: %d\n", global_counter);

    printf("Weak setting: %d\n", weak_setting);

    printf("Uninitialized: %d\n", uninitialized_global);

}

Now let’s see how different tools show these symbols:

# Using nm to view symbols (more concise output)

$ gcc -c visibility_demo.c

$ nm visibility_demo.o

0000000000000000 b local_counter

0000000000000000 t local_helper

0000000000000000 D global_counter

0000000000000000 T global_function

0000000000000000 V weak_setting

0000000000000010 W weak_function

0000000000000004 C uninitialized_global

0000000000000030 T show_symbols

                 U printf

The nm output uses single letters for symbol types:

t/T = Text (code): Lowercase for local, uppercase for global

d/D = Data (initialized): Lowercase for local, uppercase for global

b/B = BSS (uninitialized): Lowercase for local, uppercase for global

C = Common (uninitialized global)

W = Weak symbol

V = Weak object

U = Undefined (external reference)

This shorthand notation is information-dense. The case matters: a lowercase t means the symbol is local to this file—if another file also has a local symbol with the same name, there’s no conflict. An uppercase T means the symbol is global and must be unique across the entire program; two files defining the same GLOBAL symbol cause a linker error. The special Common (C) notation exists for historical reasons; it represents a symbol that may be allocated space (if uninitialized) or may get data from elsewhere (if another file initializes it). Weak symbols (W/V) are intentionally overrideable—they’re useful for defaults that users can replace.

Common Symbols: A Legacy Feature

Common symbols are a historical feature that can cause confusion:

// file1.c

int shared_buffer[100];  // Common symbol

// file2.c

int shared_buffer[200];  // Also common symbol - different size!

// file3.c

int shared_buffer[100] = {0};  // Strong symbol - initialized

Let's see what happens when we link these:

$ gcc -c file1.c file2.c file3.c

# Check symbol types

$ nm file1.o file2.o file3.o | grep shared_buffer

file1.o:0000000000000190 C shared_buffer

file2.o:0000000000000320 C shared_buffer

file3.o:0000000000000000 D shared_buffer

# Modern GCC warns about common symbols

$ gcc file1.o file2.o -o test

/usr/bin/ld: warning: alignment 4 of symbol `shared_buffer' in file2.o

             is smaller than 32 in file1.o

# Using -fno-common catches these issues at compile time

$ gcc -c -fno-common file1.c

file1.c:1:5: error: 'shared_buffer' defined but not used

Common symbols exist because early C allowed code like int x; to mean “allocate space for x here, unless someone else already allocated it.” This is actually a declaration-and-tentative-definition, not a full definition. Common symbols permitted multiple tentative definitions in different files, and the linker would pick one allocation.

This sounds reasonable until you realize: file1 reserves 100 integers, and file2 reserves 200. The linker might use one or the other, leading to silent buffer overflows or underflows—a latent bug that only appears at runtime.

Modern C treats this differently with -fno-common. Now, int shared_buffer[100] in multiple files is a hard error at compile time, forcing you to use extern int shared_buffer[100]; in headers and provide exactly one definition. This is more verbose but catches mistakes at link time instead of letting them hide until you’ve shipped to production.

Symbol Resolution: How the Linker Makes Decisions

The linker follows specific rules when multiple files define the same symbol:

// resolution_demo_weak.c

#include <stdio.h>

__attribute__((weak)) int configuration = 100;

__attribute__((weak)) void print_config(void) {

    printf("Default config: %d\n", configuration);

}

// resolution_demo_strong.c

#include <stdio.h>

int configuration = 200;  // Strong symbol overrides weak

void print_config(void) {  // Strong function overrides weak

    printf("Custom config: %d\n", configuration);

}

// resolution_demo_main.c

extern int configuration;

extern void print_config(void);

int main(void) {

    print_config();

    return 0;

}

Let’s see symbol resolution in action:

# Case 1: Only weak symbols

$ gcc -c resolution_demo_weak.c resolution_demo_main.c

$ gcc resolution_demo_weak.o resolution_demo_main.o -o demo_weak

$ ./demo_weak

Default config: 100

# Case 2: Strong overrides weak

$ gcc -c resolution_demo_strong.c

$ gcc resolution_demo_weak.o resolution_demo_strong.o resolution_demo_main.o -o demo_strong

$ ./demo_strong

Custom config: 200

# Examine symbol resolution

$ gcc -Wl,--trace-symbol=configuration resolution_demo_weak.o resolution_demo_strong.o resolution_demo_main.o -o traced

resolution_demo_weak.o: definition of configuration

resolution_demo_strong.o: definition of configuration

resolution_demo_main.o: reference to configuration

The linker’s symbol resolution rules are fundamental to C’s separate compilation model. When multiple definitions exist, the linker must decide which one to use. Strong vs. weak is the hierarchy: a strong definition always wins. If multiple strong definitions exist, that’s an error—the linker cannot arbitrate between them (that would be undefined behavior). But if a strong and weak definition coexist, the strong one is used, and the weak one is discarded.

This enables a powerful pattern: libraries can provide default implementations (weak) that users can override with custom ones (strong). This is how optional features work across complex systems; it’s also the foundation of C’s function interposition and mocking for testing.

Advanced Symbol Properties

Symbol Versioning for Library Evolution

Symbol versioning allows libraries to evolve while maintaining compatibility:

// math_lib_v1.c - Original version

#include <math.h>

// Original implementation

double calculate_distance(double x1, double y1, double x2, double y2) {

    double dx = x2 - x1;

    double dy = y2 - y1;

    return sqrt(dx * dx + dy * dy);

}

// math_lib_v2.c - Enhanced version with versioning

#include <math.h>

// Keep old version for compatibility

__asm__(".symver calculate_distance_v1,calculate_distance@MATH_1.0");

double calculate_distance_v1(double x1, double y1, double x2, double y2) {

    double dx = x2 - x1;

    double dy = y2 - y1;

    return sqrt(dx * dx + dy * dy);

}

// New improved version (default)

__asm__(".symver calculate_distance_v2,calculate_distance@@MATH_2.0");

double calculate_distance_v2(double x1, double y1, double x2, double y2) {

    // Handles overflow better

    double dx = x2 - x1;

    double dy = y2 - y1;

    return hypot(dx, dy);  // More accurate for large differences

}

Create a version script:

# math_lib.map

MATH_1.0 {

    global:

        calculate_distance;

    local:

        *;

};

MATH_2.0 {

    global:

        calculate_distance;

} MATH_1.0;

Build and examine:

$ gcc -shared -fPIC -Wl,--version-script=math_lib.map -o libmath.so.2 math_lib_v2.c -lm

# View versioned symbols

$ readelf --dyn-syms libmath.so.2 | grep calculate_distance

     5: 0000000000001139    75 FUNC    GLOBAL DEFAULT   12 calculate_distance@MATH_1.0

     6: 0000000000001186    45 FUNC    GLOBAL DEFAULT   12 calculate_distance@@MATH_2.0

# The @@ indicates the default version

Symbol versioning addresses a fundamental tension in software libraries. You want to fix bugs and improve algorithms, but millions of programs depend on your library’s current behavior. If you change calculate_distance from using sqrt to using hypot, programs compiled against your library may break—they might rely on the specific behavior or performance characteristics of the old implementation.

Versioning lets you have both.

Old programs automatically bind to calculate_distance@MATH_1.0 (using the old implementation), while new programs bind to calculate_distance@@MATH_2.0 (the new, improved version). The @@ notation means “this is the default version”—when you build new code, you get the latest one. But the old implementation remains available in the library for backward compatibility. This is why you see version numbers in library names and symbol names throughout production systems; they’re a solution to a real, critical problem.

Symbol Visibility Control

Modern shared libraries should carefully control symbol visibility:

// visibility_control.c

#include <stdio.h>

// Default visibility - exported from shared library

__attribute__((visibility("default")))

void public_api_function(void) {

    printf("This is part of the public API\n");

}

// Hidden visibility - not exported

__attribute__((visibility("hidden")))

void internal_helper(void) {

    printf("Internal implementation detail\n");

}

// Protected visibility - visible to shared libraries but not interposable

__attribute__((visibility("protected")))

void protected_function(void) {

    printf("Can be used by other libs but not overridden\n");

}

// Using pragmas for multiple declarations

#pragma GCC visibility push(hidden)

static int internal_state = 0;

void helper1(void) { internal_state++; }

void helper2(void) { internal_state--; }

#pragma GCC visibility pop

Compile and examine:

$ gcc -shared -fPIC visibility_control.c -o libvisibility.so

# Check exported symbols

$ readelf --dyn-syms libvisibility.so | grep -E "FUNC.*DEFAULT"

     7: 0000000000001139    24 FUNC    GLOBAL DEFAULT   12 public_api_function

     8: 0000000000001169    24 FUNC    GLOBAL PROTECTED 12 protected_function

# Note: internal_helper, helper1, and helper2 are not in dynamic symbol table

Symbol visibility is about managing your library’s contract with the outside world. By default, every global symbol you define is exported from your shared library, becoming part of your API. This creates two problems: first, users might depend on implementation details you wanted to keep private; second, it clutters the global namespace. If your library exports a function called parse() and someone else’s library also exports parse(), they conflict.

By marking internal functions as hidden, you tell the linker “this function is only for use within this library.” It doesn’t appear in the dynamic symbol table, so it can’t be called from outside and can’t conflict with other libraries’ internal functions. For your public API, you explicitly mark those as default, making your API boundaries clear. The protected visibility is a middle ground: other libraries can see and call the function, but they can’t override it—calls within the library always use the library’s own version, not an overridden one.

This is good library hygiene. It reduces namespace pollution, prevents accidental dependencies on implementation details, and makes linking more efficient (the linker doesn’t need to create PLT entries for hidden symbols).

Practical Symbol Debugging

Finding Symbol Problems

Let’s create a program with common symbol issues:

// lib_provider.c - Library that provides symbols

void required_function(void) {

    // Implementation

}

__attribute__((weak))

void optional_function(void) {

    // Default implementation

}

// lib_user.c - Code that uses the library

extern void required_function(void);

extern void optional_function(void);

extern void missing_function(void);  // Oops, not provided!

void use_library(void) {

    required_function();

    optional_function();

    missing_function();  // This will cause a link error

}

Debug the linking process:

$ gcc -c lib_provider.c lib_user.c

# Check for undefined symbols before linking

$ nm --undefined-only lib_user.o

                 U missing_function

                 U optional_function

                 U required_function

# Try to link - will fail

$ gcc lib_provider.o lib_user.o -o program

/usr/bin/ld: lib_user.o: in function `use_library':

lib_user.c:(.text+0x19): undefined reference to `missing_function'

collect2: error: ld returned 1 exit status

# Use linker verbosity to trace symbol resolution

$ gcc lib_provider.o lib_user.o -o program -Wl,--verbose 2>&1 | grep -A2 "missing_function"

lib_user.o: In function `use_library':

lib_user.c:(.text+0x19): undefined reference to `missing_function'

Symbol Interposition for Debugging

Create a debugging wrapper using symbol interposition:

// malloc_debug.c - Debug memory allocations

#define _GNU_SOURCE

#include <stdio.h>

#include <dlfcn.h>

#include <stdlib.h>

// Function pointer to real malloc

static void* (*real_malloc)(size_t) = NULL;

static size_t total_allocated = 0;

void* malloc(size_t size) {

    // Initialize on first call

    if (!real_malloc) {

        real_malloc = dlsym(RTLD_NEXT, "malloc");

        if (!real_malloc) {

            fprintf(stderr, "Error: cannot find real malloc\n");

            exit(1);

        }

    }

    void* ptr = real_malloc(size);

    if (ptr) {

        total_allocated += size;

        fprintf(stderr, "MALLOC: %zu bytes (total: %zu) -> %p\n",

                size, total_allocated, ptr);

    }

    return ptr;

}

// test_program.c

#include <stdlib.h>

#include <string.h>

int main(void) {

    char *buffer = malloc(100);

    strcpy(buffer, "Hello, World!");

    free(buffer);

    return 0;

}

Use the interposition:

$ gcc -shared -fPIC malloc_debug.c -o malloc_debug.so -ldl

$ gcc test_program.c -o test_program

# Normal execution

$ ./test_program

# (no output)

# With debug malloc

$ LD_PRELOAD=./malloc_debug.so ./test_program

MALLOC: 100 bytes (total: 100) -> 0x5555555592a0

MALLOC: 1024 bytes (total: 1124) -> 0x5555555592a0

MALLOC: 472 bytes (total: 1596) -> 0x555555559700

# Note: Additional allocations are from stdio initialization

Symbol interposition reveals how the linker’s symbol resolution process actually works. When a program calls malloc(), the linker initially doesn’t know whether this refers to the libc implementation or something else—it just records “this program references a symbol named malloc.” At runtime, when you preload our debug library, the dynamic linker searches for symbols in this order: first in your LD_PRELOAD library (where it finds our malloc), then in the main program, then in standard libraries. It uses the first match, which is our wrapper.

Inside our wrapper, we call dlsym(RTLD_NEXT, “malloc”), which says “find the next occurrence of malloc in the search order—skip the one we just loaded from.” This gets us the real libc malloc. We wrap it with logging, call the real one, then return. This is symbol interposition: replacing one symbol with another through linker mechanics alone, without changing any code.

This pattern is incredibly useful for debugging, profiling, and testing. It’s also a profound demonstration of how dynamic linking defers decisions to runtime, enabling flexibility that static linking cannot provide.

Symbol Tables in Shared Libraries

Shared libraries have multiple symbol tables:

// shared_lib_demo.c

#include <stdio.h>

// Exported function

void public_function(void) {

    printf("Public function called\n");

}

// Internal function - still in .symtab but not .dynsym

static void private_function(void) {

    printf("Private function called\n");

}

// Called by public function

void internal_but_global(void) {

    private_function();

}

Build and analyze:

$ gcc -shared -fPIC shared_lib_demo.c -o libdemo.so

# .dynsym - Dynamic symbol table (used at runtime)

$ readelf --dyn-syms libdemo.so | grep -E "FUNC.*DEFAULT.*12"

     6: 0000000000001139    24 FUNC    GLOBAL DEFAULT   12 public_function

     7: 0000000000001169    19 FUNC    GLOBAL DEFAULT   12 internal_but_global

# .symtab - Full symbol table (includes static functions)

$ readelf --symbols libdemo.so | grep -E "FUNC.*DEFAULT.*12"

    43: 0000000000001119    24 FUNC    LOCAL  DEFAULT   12 private_function

    48: 0000000000001139    24 FUNC    GLOBAL DEFAULT   12 public_function

    52: 0000000000001169    19 FUNC    GLOBAL DEFAULT   12 internal_but_global

# Strip removes .symtab but keeps .dynsym

$ strip libdemo.so

$ readelf --symbols libdemo.so

# (no symbol table)

$ readelf --dyn-syms libdemo.so | grep -E "FUNC.*DEFAULT"

# (dynamic symbols still present)

The runtime linker only needs the dynamic symbol table (.dynsym). It uses this to find symbols when resolving dependencies—it never looks at .symtab. The static symbol table (.symtab) exists for developers: debuggers use it to show you function names and source-level information; developers use nm to explore the binary’s internals.

This distinction has important implications. When you strip a binary to reduce size, you remove .symtab but leave .dynsym intact. The program still runs perfectly—.dynsym has everything needed for runtime linking. But debugging becomes harder; gdb can’t map machine instructions back to function names without the static symbol table.

It also has security implications: .symtab contains the names of internal functions that were supposed to be hidden. If you strip it, those internal functions become harder for attackers to find. This is why production systems often strip binaries for deployment while keeping unstripped versions for debugging.

Performance Impact of Symbol Resolution

How much does the type of symbol affect function call performance? It’s a common question, especially when considering whether to hide functions or use weak linkage. Here, let’s measure the cost of calling different kinds of symbols—static, global, and weak—in a tight loop.

Below is a benchmarking program. We’ll call each function type 100 million times and time the duration. This gives a rough sense of how fast the compiler/linker can resolve them.

// symbol_performance.c

#include <stdio.h>

#include <time.h>

// Different symbol types

static int static_function(int x) { return x * 2; }

int global_function(int x) { return x * 2; }

__attribute__((weak)) int weak_function(int x) { return x * 2; }

// Hidden symbol in shared library (for completeness, not benchmarked here)

__attribute__((visibility("hidden")))

int hidden_function(int x) { return x * 2; }

// Measure call overhead

void benchmark_symbols(void) {

    const int iterations = 100000000;

    clock_t start, end;

    volatile int result;

    // Static function

    start = clock();

    for (int i = 0; i < iterations; i++) {

        result = static_function(i);

    }

    end = clock();

    printf("Static function: %.3f seconds\n",

           (double)(end - start) / CLOCKS_PER_SEC);

    // Global function

    start = clock();

    for (int i = 0; i < iterations; i++) {

        result = global_function(i);

    }

    end = clock();

    printf("Global function: %.3f seconds\n",

           (double)(end - start) / CLOCKS_PER_SEC);

    // Weak function

    start = clock();

    for (int i = 0; i < iterations; i++) {

        result = weak_function(i);

    }

    end = clock();

    printf("Weak function: %.3f seconds\n",

           (double)(end - start) / CLOCKS_PER_SEC);

}

int main(void) {

    benchmark_symbols();

    return 0;

}

To run this experiment yourself:

1.

Save the code above into a file named symbol_performance.c.

Compile it with optimization enabled (this prevents the compiler from optimizing away your loops):

gcc -O2 -o symbol_performance symbol_performance.c

2.

Run the program and observe the timings:

./symbol_performance

3.

You should see output similar to

Static function: 0.520 seconds

Global function: 0.522 seconds

Weak function: 0.524 seconds

What should you expect?

In most cases, you’ll find no measurable difference between static, global, and weak function calls. The compiler and linker resolve them at build time when possible, so the symbol type has negligible effect on call overhead within a single translation unit.

If, however, you placed the weak symbol in a separate library and overrode it (as in Section X) or if you measure indirect (e.g., dlsym-resolved) calls, the overhead can be higher.

This experiment gives you confidence: for code within the same file or executable, don’t worry about the micro-performance of symbol type—focus on correctness and proper linkage semantics instead.

Summary

Symbols are the fundamental mechanism that enables modular programming in C. Through our exploration, we’ve discovered

1.

Every identifier becomes a symbol with properties that control visibility and linking behavior.

2.

Symbol binding determines scope: LOCAL (static), GLOBAL (default), WEAK (overrideable), or COMMON (legacy).

3.

The linker resolves symbols using priority rules: strong symbols override weak ones, initialized override common.

4.

Modern practices favor explicit control: use -fno-common, visibility attributes, and version scripts.

5.

Symbol tables enable debugging: tools like nm, readelf, and objdump reveal the symbol structure.

Key Insights from Real-World Usage

Line 16 in our first readelf output (U printf) shows undefined symbols that need external resolution.

The @@ notation (line 6 in versioned symbols) indicates the default version of a symbol.

Dynamic symbol tables (.dynsym) survive stripping, while static tables (.symtab) don’t.

Symbol interposition via LD_PRELOAD enables powerful debugging but requires careful use.

Understanding symbols transforms mysterious linker errors into solvable problems. When you see “undefined reference” or “multiple definition” errors, you now know exactly what’s happening: the linker is trying to build its address book and finding either missing entries or conflicting ones.

In Chapter 8, we’ll explore how to control where these symbols end up in memory using linker scripts, giving you complete control over your program’s memory layout.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_8

8. Customizing the Layout: Introduction to Linker Scripts

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

When embedded systems developers first encounter linker scripts, they often wonder: “Why do I need to control where my code goes in memory?” The answer becomes crystal clear when your carefully crafted firmware crashes because the stack collided with your heap or when your bootloader can’t find the interrupt vectors because they’re in the wrong location. But linker scripts aren’t just for firmware.

Even on modern Linux systems, understanding how to control memory layout helps you optimize performance, ensure security through memory isolation, and understand why your program is organized in the way it is.

In this chapter, we’ll demystify linker scripts by working through examples that you can actually run on your system right now. We’ll start with default linker behavior on desktop Linux, then progressively take control to demonstrate key concepts.

The Mystery of Memory Layout

Let’s start by examining where the linker places our code by default on a standard Linux system. Understanding the default behavior is the foundation for customizing it.

// memory_test.c

#include <stdio.h>

// Different types of data to observe placement

const char firmware_version[] = "v1.0.0";  // Read only data

int initialized_var = 42;                   // Initialized data

int uninitialized_var;                      // BSS section

char large_buffer[8192];                    // Large BSS allocation

void critical_function(void) {

    printf("This is a critical function\n");

}

int main(void) {

    printf("Firmware version: %s\n", firmware_version);

    printf("Variables at: %p, %p\n",

           &initialized_var, &uninitialized_var);

    return 0;

}

Compile this program and examine the default memory layout:

$ gcc -o memory_test memory_test.c

$ size --format=Berkeley memory_test

   text    data     bss     dec     hex filename

   1725     600    8224   10549    2935 memory_test

The size command tells us how much space each section occupies. Now let’s see where the linker actually placed everything in the final executable:

$ readelf --sections memory_test | grep -E "\.text|\.data|\.bss|\.rodata"

  [14] .text             PROGBITS         0000000000001060  00001060

       0000000000000225  0000000000000000  AX       0     0     16

  [16] .rodata           PROGBITS         0000000000002000  00002000

       000000000000001b  0000000000000000   A       0     0     4

  [24] .data             PROGBITS         0000000000004000  00003000

       0000000000000014  0000000000000000  WA       0     0     8

  [25] .bss              NOBITS           0000000000004020  00003014

       0000000000002010  0000000000000000  WA       0     0     32

Notice how the sections are placed at seemingly arbitrary addresses? Let’s understand what we’re seeing:

The .text section (containing your compiled code) starts at virtual address 0x1060. The .rodata section (constants) starts at 0x2000. Initialized variables go to .data at 0x4000, while uninitialized variables occupy .bss starting at 0x4020. These addresses work fine for a desktop application because the operating system handles virtual memory translation for you. But they demonstrate an important principle: the linker places sections according to default rules that may not match your specific needs.

Understanding Linker Scripts: Your Memory Map Blueprint

A linker script is essentially a blueprint that tells the linker exactly where to place each section of your code and data. Rather than accepting default placements, you can specify: “Put all critical functions here, keep data sections together there, align stack memory this way.” Before we write a custom script, let’s see the default one:

$ gcc --verbose -o /dev/null memory_test.c 2>&1 | grep "collect2"

This reveals that the linker is using a built-in default script. You can actually view the default script by running

$ ld --verbose | head -100

The output is quite long, but it shows all the logic that controls where sections go. Rather than modifying that complex default, we’ll create our own simpler version to understand the mechanics.

Creating Your First Custom Linker Script

Let’s create a custom linker script for Linux that demonstrates the key concepts. This script will control section placement, define memory regions, and provide symbols that your code can reference.

/* custom_layout.ld - Custom linker script for Linux */

/* Step 1: Define memory regions */

MEMORY

{

    RAM (rwx)  : ORIGIN = 0x400000, LENGTH = 32M

}

/* Step 2: Define the entry point */

ENTRY(_start)

/* Step 3: Define sections and their placement */

SECTIONS

{

    /* Set location counter to start of RAM region */

    . = 0x400000;

    /* Code section - read and execute only */

    .text ALIGN(0x1000) :

    {

        . = ALIGN(0x1000);

        *(.text.startup)

        *(.text)

        *(.text*)

        . = ALIGN(0x1000);

        _etext = .;

    } > RAM

    /* Read only data section - constants and strings */

    .rodata ALIGN(0x1000) :

    {

        . = ALIGN(0x1000);

        *(.rodata)

        *(.rodata*)

        _erodata = .;

    } > RAM

    /* Initialized data section */

    .data ALIGN(0x1000) :

    {

        . = ALIGN(0x1000);

        _sdata = .;

        *(.data)

        *(.data*)

        . = ALIGN(0x1000);

        _edata = .;

    } > RAM

    /* Uninitialized data section */

    .bss ALIGN(0x1000) :

    {

        . = ALIGN(0x1000);

        _sbss = .;

        *(.bss)

        *(.bss*)

        *(COMMON)

        . = ALIGN(0x1000);

        _ebss = .;

    } > RAM

}

Now, let’s create a program that can actually use this linker script. We’ll compile it with our custom script and observe the results:

$ gcc -c memory_test.c -o memory_test.o

$ gcc -Wl,-T,custom_layout.ld -o memory_test_custom memory_test.o

Important

The linker script sets the location counter (.) to 0x400000 at the start of the SECTIONS block. This ensures all sections are placed within the defined RAM region. Without this explicit assignment, the linker would start placing sections from a default address (typically 0x1000), which would be outside the RAM region and cause linker errors like “section .text’ is not within region RAM’”.

The -Wl,-T,custom_layout.ld flag tells gcc to pass the linker script to the linker. Let’s examine what this custom script produced:

$ readelf --sections memory_test_custom | grep -E "\.text|\.data|\.bss|\.rodata"

Output (Ubuntu 22.04):

 [ 1] .text            PROGBITS         0000000000400000  00001000

 [ 7] .rodata          PROGBITS         0000000000402000  00003000

 [21] .data            PROGBITS         0000000000403000  00004000

 [27] .bss             NOBITS           0000000000405000  00005250

Output (Fedora 43):

 [ 1] .text            PROGBITS         0000000000400000  00001000

 [ 5] .rodata          PROGBITS         0000000000402000  00003000

 [19] .data            PROGBITS         0000000000403000  00004000

 [25] .bss             NOBITS           0000000000405000  00005218

Notice the difference! With our custom linker script, sections are now placed at 0x400000, 0x402000, 0x403000, and 0x405000, exactly as we specified with alignment at 4096 byte boundaries (shown as 0x1000 in hexadecimal). The linker respected our blueprint precisely. The key fix was explicitly setting the location counter (. = 0x400000;) at the start of the SECTIONS block to ensure sections are placed within the defined RAM region.

Practical Linker Script Concepts

Understanding Memory Regions

The MEMORY block in a linker script defines named memory areas and their properties. On desktop Linux, we typically work with a single memory region (RAM), but the concept becomes crucial in embedded systems where you might have separate flash memory, on-chip RAM, external RAM, and so forth.

Each memory region has three key properties:

The name you give it (like RAM, FLASH, or SRAM). The origin address where the region starts in memory. The length, how much space the region contains.

The attributes (rwx) specify what operations are allowed: read (r), write (w), and execute (x). So (rwx) means this region allows all three, while flash might be (rx), allowing only reading and execution.

Understanding Sections

Sections are groups of code or data with common properties. The linker script tells the linker which input sections (from your object files) go into which output sections (in the final executable) and where those output sections should be placed in memory.

The dot (.) is special in linker scripts. It represents the current position in memory. When you write . = ALIGN(0x1000), you’re saying “move the current position forward until it’s aligned to a 4096 byte boundary.”

The ALIGN function is crucial for performance and security. Many systems have cache lines or memory protection units that require certain alignments. By aligning sections at 4096 bytes, we’re aligning to the system page size, which helps with virtual memory efficiency.

Load Address vs. Runtime Address

In Linux on x86-64, code and data are typically loaded into RAM at runtime, so there’s no distinction. However, linker scripts support “AT > RAM” syntax, which says “load this section from one place but run it from another.” This becomes critical in embedded systems where you might load code from flash but execute it from faster RAM.

Advanced Linker Script Features

Separating Critical Code

Let’s create a more advanced linker script that separates critical functions into their own memory region for better CPU cache utilization. This is a real optimization technique used in performance-sensitive code.

/* performance_layout.ld - Optimize for cache locality */

MEMORY

{

    RAM (rwx)  : ORIGIN = 0x400000, LENGTH = 32M

}

ENTRY(_start)

SECTIONS

{

        /* Set location counter to start of RAM region */

        . = 0x400000;

    /* Critical functions grouped together for better cache performance */

    .text.critical ALIGN(0x1000) :

    {

        . = ALIGN(0x1000);

        *(.text.startup)

        *(.text.hot*)

        . = ALIGN(0x1000);

    } > RAM

    /* Regular code */

    .text ALIGN(0x1000) :

    {

        . = ALIGN(0x1000);

        *(.text)

        *(.text*)

        . = ALIGN(0x1000);

        _etext = .;

    } > RAM

    /* Read only data */

    .rodata ALIGN(0x1000) :

    {

        . = ALIGN(0x1000);

        *(.rodata)

        *(.rodata*)

    } > RAM

    /* Data sections */

    .data ALIGN(0x1000) :

    {

        . = ALIGN(0x1000);

        _sdata = .;

        *(.data)

        *(.data*)

        . = ALIGN(0x1000);

        _edata = .;

    } > RAM

    .bss ALIGN(0x1000) :

    {

        . = ALIGN(0x1000);

        _sbss = .;

        *(.bss)

        *(.bss*)

        *(COMMON)

        . = ALIGN(0x1000);

        _ebss = .;

    } > RAM

}

To use this optimization, compile performance-sensitive functions with a special section attribute. Here’s a complete example:

// performance_test.c - Demonstrate section attributes for performance optimization

#include <stdio.h>

// Regular function - will be placed in .text section

void regular_function(void) {

    printf("This is a regular function\n");

}

// Performance-critical function - will be placed in .text.hot section

// The linker script collects all .text.hot* sections into .text.critical

__attribute__((section(".text.hot")))

void performance_critical_function(void) {

    printf("This is a performance-critical function\n");

}

// Another hot function

__attribute__((section(".text.hot")))

void another_hot_function(void) {

    printf("Another hot function\n");

}

int main(void) {

    regular_function();

    performance_critical_function();

    another_hot_function();

    return 0;

}

Compile and link:

$ gcc -c performance_test.c -o performance_test.o

$ gcc -Wl,-T,performance_layout.ld -o performance_test performance_test.o

Now the critical functions are grouped together in the .text.critical section, improving cache line efficiency and potentially reducing instruction cache misses. You can verify this worked by examining the sections:

$ readelf --sections performance_test | grep -E '\.text|Name'

Output

 [Nr] Name              Type             Address           Offset

 [ 1] .text.critical    PROGBITS         0000000000400000  00001000

 [ 2] .text             PROGBITS         0000000000401000  00002000

You can also verify which functions are in which section using nm:

$ nm performance_test | grep -E '(performance_critical|another_hot|regular_function)'

Output

000000000040001a T another_hot_function

0000000000400000 T performance_critical_function

00000000004010e9 T regular_function

Notice that performance_critical_function and another_hot_function are both in the .text.critical section (addresses starting at 0x400000), while regular_function is in the regular .text section (address 0x4010e9). The linker script’s *(.text.hot*) pattern collects all functions marked with __attribute__((section(“.text.hot”))) into the .text.critical section.

Exporting Linker Symbols

One powerful feature of linker scripts is the ability to define symbols that your code can reference. The startup code I showed earlier used _sdata, _edata, _sbss, and _ebss symbols to know where each section is located so it can initialize them.

Let’s create a program that uses linker-provided symbols to understand memory layout at runtime:

// analyze_layout.c

#include <stdio.h>

#include <stdint.h>

/* These symbols are defined by the linker script */

extern uint32_t _etext;    /* End of .text section */

extern uint32_t _rodata;   /* Start of .rodata section */

extern uint32_t _sdata;    /* Start of .data section */

extern uint32_t _edata;    /* End of .data section */

extern uint32_t _sbss;     /* Start of .bss section */

extern uint32_t _ebss;     /* End of .bss section */

int main(void) {

    printf("Memory Layout Analysis\n");

    printf("=====================\n\n");

    printf("Code section (.text):\n");

    printf("  Ends at: %p\n\n", &_etext);

    printf("Initialized data (.data):\n");

    printf("  Starts at: %p\n", &_sdata);

    printf("  Ends at: %p\n", &_edata);

    printf("  Size: %zu bytes\n\n", (size_t)(&_edata - &_sdata));

    printf("Uninitialized data (.bss):\n");

    printf("  Starts at: %p\n", &_sbss);

    printf("  Ends at: %p\n", &_ebss);

    printf("  Size: %zu bytes\n\n", (size_t)(&_ebss - &_sbss));

    return 0;

}

Compile this with the custom linker script:

$ gcc -Wl,-T,custom_layout.ld -o analyze_layout analyze_layout.c

$ ./analyze_layout

Memory Layout Analysis

=====================

Code section (.text):

  Ends at: 0x400200

Initialized data (.data):

  Starts at: 0x402000

  Ends at: 0x402030

  Size: 48 bytes

Uninitialized data (.bss):

  Starts at: 0x403000

  Ends at: 0x405010

  Size: 8208 bytes

These linker-provided symbols are powerful. Your code can now understand its own memory layout, validate assumptions at runtime, and make decisions based on available space. In embedded systems, this is essential for checking stack/heap collision or validating that firmware fits within allocated space.

Debugging Linker Scripts

When things go wrong with linker scripts, the map file is your best diagnostic tool. It shows exactly how the linker interpreted your script and where everything ended up.

$ gcc -Wl,-T,custom_layout.ld -Wl,-Map=output.map -o memory_test memory_test.o

$ cat output.map | head -50

The map file output shows your script, memory region usage, and the location of every symbol. This is invaluable when debugging linker errors.

Common Issues and Solutions

If your linker script produces an error like “region RAM overflowed by 1234 bytes,” it means your code and data together exceed the space you’ve allocated. The solution is either to increase the LENGTH in the MEMORY block or reduce how much code/data you’re linking.

If sections appear in unexpected locations, double-check your ALIGN() directives and the order of sections in your script. The linker processes sections in the order they appear, so reordering can change the layout.

Practical Performance Impact

Let’s measure how section placement actually affects performance. Memory alignment influences cache efficiency, which can significantly impact performance on modern CPUs.

// cache_test.c

#include <stdio.h>

#include <time.h>

#include <stdint.h>

/* Place these at different alignment boundaries */

__attribute__((section(".data.cache_test")))

volatile int cache_test_array[1024];

volatile int result = 0;

int main(void) {

    /* Fill cache lines with data */

    for (int i = 0; i < 1024; i++) {

        cache_test_array[i] = i;

    }

    /* Access in order (good cache performance) */

    clock_t start = clock();

    for (int iter = 0; iter < 10000; iter++) {

        for (int i = 0; i < 1024; i++) {

            result += cache_test_array[i];

        }

    }

    clock_t end = clock();

    double elapsed = ((double)(end - start)) / CLOCKS_PER_SEC;

    printf("Sequential access: %.3f seconds\n", elapsed);

    printf("Result (used to prevent optimization): %d\n", result);

    return 0;

}

Compile and run this:

$ gcc -O2 -o cache_test cache_test.c

$ ./cache_test

Sequential access: 0.015 seconds

Result (used to prevent optimization): 5120000

With proper section alignment in the linker script, this sequential access pattern benefits from CPU prefetching and cache line optimization. If sections were misaligned or scattered, cache misses would increase this time significantly.

Summary

Linker scripts transform from mysterious configuration files into powerful tools once you understand their purpose and syntax. Even on desktop Linux, they give you precise control over your program’s memory layout, enabling

Optimal performance through strategic code placement and cache alignment

Efficient memory usage by controlling section organization

Validation of memory assumptions through linker-defined symbols

Debugging and understanding of program structure

Key Takeaways from our Exploration

The MEMORY block defines available memory regions and their properties.

SECTIONS specify where input sections end up in output memory.

Symbols can be defined by the linker script for runtime code to use.

Alignment (ALIGN directive) affects cache efficiency and page boundaries.

The map file shows exactly how the linker interpreted your script.

In Chapter 9, we’ll explore how dynamic linking adds another layer of flexibility to program loading, building on the foundation of static linking we’ve covered here.

Practical Exercises

1.

Create a linker script that places all printf-related code in one section.

2.

Modify the custom layout to use different page alignments, and measure the performance difference.

3.

Write code that detects stack overflow by comparing the stack pointer against the _sbss symbol.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_9

9. Dynamic Linking in C: Shrinking Executables and Sharing Code

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

When developers first discover that their simple “Hello, World!” program is 16KB instead of a few hundred bytes, they often wonder where all those extra bytes come from. The answer lies partly in static linking—every program carries its own copy of library code. But what if multiple programs could share the same library code? Enter dynamic linking, a clever solution that not only shrinks executables but also enables features that would be impossible with static linking alone.

The Cost of Static Linking: A Sobering Example

Let’s start by creating several programs that use common C library functions:

// program1.c

#include <stdio.h>

#include <math.h>

#include <unistd.h>

int main(void) {

    printf("Sin(1.0) = %f\n", sin(1.0));

        sleep(10); // Keep process alive for memory inspection

    return 0;

}

// program2.c

#include <stdio.h>

#include <math.h>

#include <unistd.h>

int main(void) {

    printf("Cos(1.0) = %f\n", cos(1.0));

        sleep(10); // Keep process alive for memory inspection

    return 0;

}

// program3.c

#include <stdio.h>

#include <math.h>

#include <unistd.h>

int main(void) {

    printf("Tan(1.0) = %f\n", tan(1.0));

        sleep(10); // Keep process alive for memory inspection

    return 0;

}

Now let’s compile these programs both statically and dynamically to see the difference:

# Dynamic linking (default)

$ gcc -o program1_dynamic program1.c -lm

$ gcc -o program2_dynamic program2.c -lm

$ gcc -o program3_dynamic program3.c -lm

# Static linking

$ gcc -static -o program1_static program1.c -lm

$ gcc -static -o program2_static program2.c -lm

$ gcc -static -o program3_static program3.c -lm

# Compare sizes

$ ls -lh program*

-rwxr-xr-x 1 user user  16K Nov 10 10:00 program1_dynamic

-rwxr-xr-x 1 user user 833K Nov 10 10:00 program1_static

-rwxr-xr-x 1 user user  16K Nov 10 10:00 program2_dynamic

-rwxr-xr-x 1 user user 833K Nov 10 10:00 program2_static

-rwxr-xr-x 1 user user  16K Nov 10 10:00 program3_dynamic

-rwxr-xr-x 1 user user 833K Nov 10 10:00 program3_static

The results are striking:

Dynamic Executables: 16KB each × 3 = 48KB total

Static Executables: 833KB each × 3 = 2,499KB total

Static linking uses 52× more disk space!

Why is this happening? When you statically link, the linker embeds the entire library code—all mathematical functions, error handling, memory management routines, and more—directly into your executable. Each program gets its own complete copy of libm, including functions it doesn’t even use. With dynamic linking, all three programs reference the same shared library file on disk. Each program pays only the small cost of metadata describing where to find the library (around 16KB), not the entire library itself.

But the real magic happens when we run these programs. To inspect their memory maps, we need programs that stay running. The programs shown above already include “sleep(10)” to keep them alive for inspection.

Now check memory usage using “/proc/PID/maps”:

# Check memory usage (using pmap)

# Start programs in background

$ ./program1_dynamic & PID1=$!

$ ./program2_dynamic & PID2=$!

$ ./program3_dynamic & PID3=$!

$ pmap $PID1 | grep libc

00007f8a12345000   1772K r-xp- libc-2.31.so

00007f8a12500000    316K r--p- libc-2.31.so

# Wait a moment for them to start

$ sleep 1

# Check memory maps

$ cat /proc/$PID1/maps | grep libc

Output (Ubuntu 22.04)

7fffff563000-7fffff58b000 r--p 00000000 00:3f 33821919                   /usr/lib/x86_64-linux-gnu/libc.so.6

7fffff58b000-7fffff720000 r-xp 00028000 00:3f 33821919                   /usr/lib/x86_64-linux-gnu/libc.so.6

7fffff720000-7fffff778000 r--p 001bd000 00:3f 33821919                   /usr/lib/x86_64-linux-gnu/libc.so.6

Output (Fedora 43)

7fffff5a0000-7fffff710000 r-xp 00000000 00:51 34030806                   /usr/lib64/libc.so.6

7fffff710000-7fffff786000 r--p 00170000 00:51 34030806                   /usr/lib64/libc.so.6

7fffff786000-7fffff78a000 r--p 001e5000 00:51 34030806                   /usr/lib64/libc.so.6

7fffff78a000-7fffff78c000 rw-p 001e9000 00:51 34030806                   /usr/lib64/libc.so.6

All three processes map the same libc library. The addresses differ due to ASLR, but they reference the same physical pages in memory.

# Clean up

$ kill $PID1 $PID2 $PID3

This is the profound insight: when multiple programs run simultaneously, they all map the same physical library pages into their virtual address spaces. The operating system’s page cache ensures that these pages are only loaded into RAM once. So while your disk has three separate 833KB executables, your RAM contains only a single copy of the library code, shared across all three processes. This is one of the most elegant optimizations in modern operating systems.

Creating Your First Shared Library

Let’s build a shared library from scratch to understand how dynamic linking works:

// mathops.h

#ifndef MATHOPS_H

#define MATHOPS_H

// Function declarations with detailed documentation

double add(double a, double b);

double subtract(double a, double b);

double multiply(double a, double b);

double divide(double a, double b);

// Version information

const char* mathops_version(void);

#endif

// mathops.c

#include "mathops.h"

#include <stdio.h>

// Add version information

const char* mathops_version(void) {

    return "MathOps Library v1.0.0";

}

double add(double a, double b) {

    printf("[MathOps] Adding %.2f + %.2f\n", a, b);

    return a + b;

}

double subtract(double a, double b) {

    printf("[MathOps] Subtracting %.2f - %.2f\n", a, b);

    return a - b;

}

double multiply(double a, double b) {

    printf("[MathOps] Multiplying %.2f × %.2f\n", a, b);

    return a * b;

}

double divide(double a, double b) {

    printf("[MathOps] Dividing %.2f ÷ %.2f\n", a, b);

    if (b == 0) {

        fprintf(stderr, "[MathOps] ERROR: Division by zero!\n");

        return 0;

    }

    return a / b;

}

Building the Shared Library

Let’s compile our library step by step, understanding each flag:

# Step 1: Compile to position-independent object file

$ gcc -c -fPIC mathops.c -o mathops.o

# Let's see what -fPIC does

$ objdump --reloc mathops.o | head -20

mathops.o:     file format elf64-x86-64

RELOCATION RECORDS FOR [.text]:

OFFSET           TYPE              VALUE

000000000000001a R_X86_64_PC32     .rodata-0x0000000000000004

000000000000002f R_X86_64_PLT32    printf-0x0000000000000004

The -fPIC flag generates position-independent code, which uses relative addressing so the code can be loaded at any memory address.

The significance of -fPIC cannot be overstated. Normally, compiled code contains absolute references: “the variable x lives at address 0x12345000.” But when multiple processes load the same shared library, they can’t all use those same addresses—virtual address spaces are isolated per process. Instead, position-independent code uses relative addressing: “the variable x lives 4096 bytes after my current position.”

This allows the kernel to load the same library at different addresses in different processes’ address spaces. The relocation records shown above—like R_X86_64_PC32—represent these relative references. Without -fPIC, a library can only be loaded at one fixed address system-wide, making it incompatible with modern security features like ASLR (Address Space Layout Randomization).

# Step 2: Create the shared library

$ gcc -shared -o libmathops.so mathops.o

# Examine the library

$ file libmathops.so

libmathops.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),

dynamically linked, not stripped

# Check exported symbols

$ nm --dynamic libmathops.so

0000000000001139 T add

0000000000001213 T divide

00000000000011a8 T multiply

0000000000001170 T subtract

0000000000001110 T mathops_version

                 U printf@@GLIBC_2.2.5

                 U fprintf@@GLIBC_2.2.5

The --shared flag tells the linker to create a shared object rather than an executable. Notice the output of file—it correctly identifies this as a “shared object.” The nm output reveals something important about shared library construction: while our library exports functions like add and multiply (marked T for text, meaning defined), it also has unresolved references to printf and fprintf (marked U for undefined). These will be resolved at runtime when our library is loaded into a process that also has libc loaded.

Using the Library: Method 1—Compile-Time Linking

// calculator.c

#include <stdio.h>

#include "mathops.h"

int main(void) {

    printf("MathOps Calculator - %s\n", mathops_version());

    double x = 10.0, y = 3.0;

    printf("\nResults:\n");

    printf("%.2f + %.2f = %.2f\n", x, y, add(x, y));

    printf("%.2f - %.2f = %.2f\n", x, y, subtract(x, y));

    printf("%.2f × %.2f = %.2f\n", x, y, multiply(x, y));

    printf("%.2f ÷ %.2f = %.2f\n", x, y, divide(x, y));

    // Test error handling

    printf("\nError test:\n");

    divide(5.0, 0.0);

    return 0;

}

Compile and link:

# Compile and link against our library

$ gcc calculator.c -L. -lmathops -o calculator

# Try to run - it fails!

$ ./calculator

./calculator: error while loading shared libraries: libmathops.so:

cannot open shared object file: No such file or directory

# Check dependencies

$ ldd calculator

    linux-vdso.so.1 (0x00007ffd39d7c000)

    libmathops.so => not found

    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f7e5c7b6000)

    /lib64/ld-linux-x86-64.so.2 (0x00007f7e5c9d7000)

Solving the Library Path Problem

There are several ways to tell the dynamic linker where to find our library:

# Method 1: LD_LIBRARY_PATH (temporary)

$ LD_LIBRARY_PATH=. ./calculator

MathOps Calculator - MathOps Library v1.0.0

Results:

[MathOps] Adding 10.00 + 3.00

10.00 + 3.00 = 13.00

...

# Method 2: rpath (permanent, built into executable)

$ gcc calculator.c -L. -lmathops -Wl,-rpath,. -o calculator

$ ./calculator  # Works without LD_LIBRARY_PATH!

# Method 3: Install to system directory

$ sudo cp libmathops.so /usr/local/lib/

$ sudo ldconfig  # Update library cache

# Method 4: Use $ORIGIN for relative paths

$ gcc calculator.c -L. -lmathops -Wl,-rpath,'$ORIGIN' -o calculator

The library path problem occurs because the linker (gcc -L. flag) and the runtime dynamic linker are different actors with different concerns. The -L. flag tells the compile-time linker “look in the current directory for libraries I need to link against.” This succeeds because our library file is right there. But at runtime, the dynamic linker has no idea where that library went—it only knows the name libmathops.so, not its location on disk.

Method 1 works by telling the runtime linker where to search. LD_LIBRARY_PATH is an environment variable the dynamic linker checks at runtime before looking in standard locations like /lib and /usr/lib. This is temporary—if you run the program from a different directory or without this environment variable, it fails.

Method 2 uses rpath (run path), which embeds a library search path directly into the executable during linking. The -Wl,-rpath,. flag tells the compiler to pass --rpath=. to the linker, which records “look in the current directory” inside the executable itself. This is permanent—the executable carries this information wherever it goes.

Method 4 is more sophisticated: $ORIGIN is a special variable that the runtime linker replaces with the directory containing the executable itself. If you build with -rpath=‘$ORIGIN/../lib’, the executable can find libraries relative to its own location, even if you move the entire directory tree elsewhere.

Let’s examine the rpath in our executable:

$ readelf --dynamic calculator | grep -E "RPATH|RUNPATH"

 0x000000000000001d (RUNPATH)            Library runpath: [.]

Notice it’s labeled RUNPATH (modern systems) rather than RPATH (legacy). The dynamic linker checks both, but RUNPATH takes precedence. This entry tells the system “when loading this executable’s dependencies, search the current directory first.”

Understanding the Dynamic Linking Process

The GOT and PLT: Dynamic Linking’s Secret Machinery

When we call a function from a shared library, the magic happens through two key data structures:

1.

GOT (Global Offset Table): Stores actual function addresses

2.

PLT (Procedure Linkage Table): Contains stubs that handle lazy binding

Let’s see this in action:

# Disassemble to see PLT calls

$ objdump --disassemble calculator | grep -A5 "call.*add"

    11a9:    e8 92 fe ff ff        call   1040 <add@plt>

# Look at the PLT stub

$ objdump --disassemble calculator | grep -A5 "<add@plt>:"

0000000000001040 <add@plt>:

    1040:    ff 25 d2 2f 00 00     jmp    *0x2fd2(%rip)  # 4018 <add@got>

    1046:    68 00 00 00 00        push   $0x0

    104b:    e9 e0 ff ff ff        jmp    1030 <.plt>

# The GOT entry (initially points back to PLT)

$ objdump --section=.got.plt --full-contents calculator

To understand what’s happening here, let’s break down this PLT stub line by line. When your code calls add(), the compiler doesn’t emit a direct call instruction. Instead, it generates a call 1040 <add@plt> instruction that jumps to this PLT stub.

The first line of the stub—jmp *0x2fd2(%rip)—is a computed jump. The RIP register contains the current instruction pointer. Adding the offset 0x2fd2 to it and then dereferencing that memory gives us the address we jump to. Initially, this GOT entry (at address 4018) contains the address of the next instruction in the PLT (1046), not the actual add function.

So on the first call to add(), we land at line 1046, which pushes a relocation index (0x0), then jumps to the PLT resolver at address 1030. The resolver looks up which library function we need, finds the real address in libc, and patches the GOT entry to point directly to the real function. On subsequent calls, the jmp *0x2fd2(%rip) goes straight to the real function—this is lazy binding, a fundamental optimization.

Why does this complexity exist? Because the linker can’t know at compile time where shared library functions will be located in memory. By using this indirection through the GOT, we can load libraries at any address; the runtime linker fills in the GOT table with the actual addresses after loading the library.

Watching Dynamic Linking in Real Time

Let’s create a program to observe the linking process:

// link_observer.c

#define _GNU_SOURCE // Required for Dl_info and dladdr

#include <stdio.h>

#include <dlfcn.h>

#include "mathops.h"

void print_function_address(const char* name, void* addr) {

    Dl_info info;

    if (dladdr(addr, &info)) {

        printf("%-20s: %p (from %s)\n", name, addr, info.dli_fname);

    }

}

int main(void) {

    printf("=== Before first call ===\n");

    // Function pointers through PLT

    void* plt_add = (void*)&add;

    print_function_address("add@plt", plt_add);

    printf("\n=== First call ===\n");

    add(1.0, 2.0);  // Triggers symbol resolution

    printf("\n=== After first call ===\n");

    // Now the GOT is updated with the real address

    print_function_address("add (resolved)", plt_add);

    return 0;

}

Compile and run with debugging:

$ gcc link_observer.c -L. -lmathops -Wl,-rpath,. -ldl -o link_observer

# Run with LD_DEBUG to see the linking process

$ LD_DEBUG=bindings ./link_observer 2>&1 | grep -E '(libmathops|add)'

Note

The LD_DEBUG=bindings output shows the dynamic linker resolving symbols. The exact format and timing may vary depending on when lazy binding occurs. You’ll see bindings for libmathops.so and its dependencies (like libc.so.6). The add symbol binding happens when the function is first called through the PLT.

Output (Ubuntu 22.04)

=== Before first call ===

add@plt             : 0x7fffff7bd14a (from ./libmathops.so)

=== First call ===

[MathOps] Adding 1.00 + 2.00

=== After first call ===

add (resolved)      : 0x7fffff7bd14a (from ./libmathops.so)

Output (Fedora 43)

=== Before first call ===

add@plt             : 0x400370 (from ./link_observer)

=== First call ===

[MathOps] Adding 1.00 + 2.00

=== After first call ===

add (resolved)      : 0x400370 (from ./link_observer)

The program demonstrates how dladdr can inspect function addresses and determine which library they come from. Notice how the address is resolved after the first call triggers lazy binding.

Method 2: Runtime Dynamic Loading with dlopen

Sometimes we need even more flexibility—loading libraries on demand at runtime:

// plugin_system.c

#include <stdio.h>

#include <dlfcn.h>

#include <stdlib.h>

// Function pointer types for our math operations

typedef double (*binary_op)(double, double);

typedef const char* (*version_func)(void);

typedef struct {

    void* handle;

    binary_op add;

    binary_op subtract;

    binary_op multiply;

    binary_op divide;

    version_func get_version;

} MathPlugin;

MathPlugin* load_math_plugin(const char* library_path) {

    MathPlugin* plugin = malloc(sizeof(MathPlugin));

    if (!plugin) return NULL;

    // Load the library

    plugin->handle = dlopen(library_path, RTLD_LAZY);

    if (!plugin->handle) {

        fprintf(stderr, "Failed to load library: %s\n", dlerror());

        free(plugin);

        return NULL;

    }

    // Clear any existing errors

    dlerror();

    // Load symbols (with error checking)

    plugin->add = (binary_op)dlsym(plugin->handle, "add");

    plugin->subtract = (binary_op)dlsym(plugin->handle, "subtract");

    plugin->multiply = (binary_op)dlsym(plugin->handle, "multiply");

    plugin->divide = (binary_op)dlsym(plugin->handle, "divide");

    plugin->get_version = (version_func)dlsym(plugin->handle, "mathops_version");

    // Check for errors

    char* error = dlerror();

    if (error) {

        fprintf(stderr, "Symbol loading failed: %s\n", error);

        dlclose(plugin->handle);

        free(plugin);

        return NULL;

    }

    return plugin;

}

void unload_math_plugin(MathPlugin* plugin) {

    if (plugin) {

        if (plugin->handle) {

            dlclose(plugin->handle);

        }

        free(plugin);

    }

}

int main(int argc, char* argv[]) {

    const char* lib_path = argc > 1 ? argv[1] : "./libmathops.so";

    printf("Loading math plugin from: %s\n", lib_path);

    MathPlugin* math = load_math_plugin(lib_path);

    if (!math) {

        fprintf(stderr, "Failed to load plugin\n");

        return 1;

    }

    printf("Plugin loaded: %s\n", math->get_version());

    // Use the plugin

    double x = 15.0, y = 4.0;

    printf("\nCalculations:\n");

    printf("%.2f + %.2f = %.2f\n", x, y, math->add(x, y));

    printf("%.2f - %.2f = %.2f\n", x, y, math->subtract(x, y));

    printf("%.2f × %.2f = %.2f\n", x, y, math->multiply(x, y));

    printf("%.2f ÷ %.2f = %.2f\n", x, y, math->divide(x, y));

    // Clean up

    unload_math_plugin(math);

    printf("\nPlugin unloaded\n");

    return 0;

}

Whereas compile-time linking loads dependencies immediately when your program starts (determined entirely by the linker), runtime dynamic loading defers this decision to the program itself. This enables several powerful patterns:

Plug-In Systems: Your application can discover and load plug-ins from a directory at runtime, extending functionality without recompiling. A media player might load codecs on demand; a text editor might load language-specific modules when the user opens that type of file.

Feature Detection: You can gracefully handle libraries that may or may not be installed. For example, an application might attempt to load an optional GPU acceleration library, but fall back to CPU-only computation if it’s not available.

Version Selection: Unlike compile-time linking, which binds to one specific library version, runtime loading lets your program choose between multiple versions of a library at startup based on configuration or environment.

Reduced Dependencies: Your program doesn’t need to declare every possible library it might use; only the libraries you actually decide to load are required at runtime.

The trade-off is complexity: you must handle the dlopen/dlsym API yourself, and errors can occur at runtime rather than link time. The RTLD_LAZY flag is particularly important—it defers symbol resolution until symbols are actually called, enabling true lazy loading even with dynamic loading. You could use RTLD_NOW to resolve all symbols immediately upon loading, though this trades startup speed for easier error detection.

Compile and run:

$ gcc plugin_system.c -ldl -o plugin_system

$ ./plugin_system ./libmathops.so

Loading math plugin from: ./libmathops.so

Plugin loaded: MathOps Library v1.0.0

Calculations:

[MathOps] Adding 15.00 + 4.00

15.00 + 4.00 = 19.00

Advanced Topics: Symbol Versioning

As libraries evolve, we need to maintain backward compatibility. Symbol versioning helps:

// mathops_v2.c - Extended version with compatibility

#include "mathops.h"

#include <stdio.h>

#include <math.h>

// Original version (v1.0)

__asm__(".symver add_v1,add@MATHOPS_1.0");

double add_v1(double a, double b) {

    return a + b;

}

// New version (v2.0) with logging

__asm__(".symver add_v2,add@@MATHOPS_2.0");

double add_v2(double a, double b) {

    printf("[MathOps v2] Advanced add with validation\n");

    if (isnan(a) || isnan(b)) {

        fprintf(stderr, "[MathOps] Warning: NaN detected\n");

    }

    return a + b;

}

// New feature in v2.0

__asm__(".symver power,power@@MATHOPS_2.0");

double power(double base, double exp) {

    printf("[MathOps v2] Power function: %.2f^%.2f\n", base, exp);

    return pow(base, exp);

}

Create a version script:

# mathops.map

MATHOPS_1.0 {

    global:

        add;

        subtract;

        multiply;

        divide;

        mathops_version;

    local:

        *;

};

MATHOPS_2.0 {

    global:

        add;      # New version

        power;    # New function

} MATHOPS_1.0;   # Inherit from v1.0

Symbol versioning solves a critical problem in library evolution. Consider: you distribute libmathops with an add function. Millions of programs are built against this version. Now you want to improve the library, adding error checking and new functions. If you just change the add function, all those millions of old programs might break—they were compiled assuming certain behavior or performance characteristics.

With symbol versioning, you can have multiple versions of the same function coexist in the same library. Old programs automatically link to add@MATHOPS_1.0, while new programs link to add@@MATHOPS_2.0 (the @@ means “this is the default version”). The library can even contain both implementations simultaneously. When a program from 2010 loads the modern library, it gets the original add function; programs built today get the improved version.

This is why you see version suffixes in library names (libc.so.6) and in symbol output—they’re not random decorations but critical infrastructure for long-term software stability.

Build with versioning:

$ gcc -c -fPIC mathops_v2.c -o mathops_v2.o

$ gcc -shared -Wl,--version-script=mathops.map -o libmathops.so.2 mathops_v2.o -lm

# Check symbol versions

$ readelf --symbols --wide libmathops.so.2 | grep -E "add|power"

    12: 0000000000001149    39 FUNC    GLOBAL DEFAULT   12 add@@MATHOPS_2.0

    13: 0000000000001170    27 FUNC    GLOBAL DEFAULT   12 add@MATHOPS_1.0

    14: 00000000000011a0    45 FUNC    GLOBAL DEFAULT   12 power@@MATHOPS_2.0

Performance Analysis: Static vs. Dynamic Linking

Let’s measure the real performance impact:

// performance_test.c

#include <stdio.h>

#include <time.h>

#include <unistd.h>

#include <sys/wait.h>

double measure_startup_time(const char* program) {

    clock_t start = clock();

    pid_t pid = fork();

    if (pid == 0) {

        // Child process

        execl(program, program, NULL);

        perror("execl");

        _exit(1);

    }

    // Parent waits

    waitpid(pid, NULL, 0);

    clock_t end = clock();

    return ((double)(end - start)) / CLOCKS_PER_SEC;

}

int main(void) {

    // Test programs that just print and exit

    double dynamic_time = 0, static_time = 0;

    int iterations = 100;

    printf("Measuring startup time over %d iterations...\n", iterations);

    for (int i = 0; i < iterations; i++) {

        dynamic_time += measure_startup_time("./test_dynamic");

        static_time += measure_startup_time("./test_static");

    }

    printf("\nResults:\n");

    printf("Dynamic linking: %.6f seconds average\n", dynamic_time / iterations);

    printf("Static linking:  %.6f seconds average\n", static_time / iterations);

    printf("Dynamic is %.2fx slower at startup\n",

           (dynamic_time / iterations) / (static_time / iterations));

    return 0;

}

Typical results:

Dynamic Linking: 0.002145 seconds average

Static Linking: 0.001023 seconds average

Dynamic is 2.10x slower at startup.

Yes, dynamic linking does have measurable startup overhead. The dynamic linker must

1.

Load the executable.

2.

Find and open each required shared library (checking multiple paths).

3.

Map library segments into memory.

4.

Process relocations.

5.

Resolve undefined symbols (at least the ones used immediately).

6.

Initialize the library (running constructors, etc.).

That’s 2.1 milliseconds of overhead per execution. For a program that runs for hours, this is negligible. For a utility that runs and exits in milliseconds, it’s significant. The question isn’t whether the overhead exists—it clearly does—but whether it matters for your use case.

But remember

Dynamic executables are 50× smaller.

Multiple programs share library memory.

Libraries can be updated without recompiling.

On a system running many programs, the accumulated memory savings are enormous.

This is why desktop systems, servers, and embedded systems alike rely on dynamic linking. The small startup cost is vastly outweighed by the combined benefits of smaller disk usage, shared memory, and the ability to fix security vulnerabilities in libraries without recompiling every program.

Security Considerations

1. Library Injection Attacks

Dynamic linking introduces security concerns:

// malicious_override.c

#include <stdio.h>

// Override a standard library function

int rand(void) {

    printf("MALICIOUS: rand() has been hijacked!\n");

    return 42;  // Always return 42

}

Compile and demonstrate:

$ gcc -shared -fPIC malicious_override.c -o malicious.so

# Normal execution

$ ./program_using_rand

Random number: 1804289383

# With library injection

$ LD_PRELOAD=./malicious.so ./program_using_rand

MALICIOUS: rand() has been hijacked!

Random number: 42

This demonstrates a critical security principle: naming alone doesn’t secure a symbol. Because the linker searches for symbols by name and takes the first match, an attacker can create a malicious shared library with the same symbol names as a critical library, then use LD_PRELOAD to force the system to load their version first.

In this harmless example, we just changed the random number. An attacker could instead implement a malicious open() that exfiltrates data or a fake read() that injects malicious code into every file you access. The LD_PRELOAD mechanism, while invaluable for legitimate debugging and testing, is a profound security vulnerability when an attacker controls it.

The vulnerability is particularly acute for setuid binaries (programs with elevated privileges). If an attacker can control LD_PRELOAD in their own environment, they can hijack system calls of any program they run, potentially escalating privileges or accessing restricted data.

2. Protecting Against Injection

// secure_program.c

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

int main(void) {

    // Check if running with elevated privileges

    if (geteuid() != getuid()) {

        // Clear dangerous environment variables

        unsetenv("LD_PRELOAD");

        unsetenv("LD_LIBRARY_PATH");

        printf("Cleared potentially dangerous environment variables\n");

    }

    // Continue with normal operation

    printf("Secure program running...\n");

    return 0;

}

If your program runs with elevated privileges—whether via setuid bits, sudo, or capabilities—you must treat environment variables as untrusted input. The geteuid() != getuid() check identifies this situation: the effective UID (what privileges we’re running with) differs from the real UID (who we actually are). When this happens, we clear environment variables that the dynamic linker consults, preventing the attacker from controlling library loading.

This is defensive programming at the system level. The kernel could theoretically handle this automatically (some systems, like OpenBSD, have more aggressive protections), but relying on applications to protect themselves is currently the norm on Linux.

Best Practices for Library Design

1. Proper Symbol Visibility

// visibility.h

#ifndef VISIBILITY_H

#define VISIBILITY_H

// Control symbol visibility

#if defined(_WIN32) || defined(__CYGWIN__)

    #ifdef BUILDING_DLL

        #define PUBLIC __declspec(dllexport)

    #else

        #define PUBLIC __declspec(dllimport)

    #endif

    #define PRIVATE

#else

    #define PUBLIC __attribute__((visibility("default")))

    #define PRIVATE __attribute__((visibility("hidden")))

#endif

// Public API

PUBLIC int public_function(int x);

// Internal functions (not exported)

PRIVATE void internal_helper(void);

#endif

2. Library Initialization and Cleanup

// library_lifecycle.c

#include <stdio.h>

// Constructor - runs when library is loaded

__attribute__((constructor))

static void library_init(void) {

    printf("Library: Initializing resources...\n");

    // Allocate resources, open files, etc.

}

// Destructor - runs when library is unloaded

__attribute__((destructor))

static void library_cleanup(void) {

    printf("Library: Cleaning up resources...\n");

    // Free resources, close files, etc.

}

// Priority can be specified (101-65535)

__attribute__((constructor(101)))

static void early_init(void) {

    printf("Library: Early initialization\n");

}

Debugging Dynamic Linking Issues

1. Common Problems and Solutions

# Problem: "error while loading shared libraries"

$ ldd program | grep "not found"

    libcustom.so => not found

# Solution 1: Check library search path

$ LD_LIBRARY_PATH=/path/to/lib ldd program

# Solution 2: Check library architecture

$ file program

program: ELF 64-bit LSB executable...

$ file libcustom.so

libcustom.so: ELF 32-bit LSB shared object...  # Mismatch!

# Problem: "undefined symbol"

$ ./program

./program: symbol lookup error: undefined symbol: missing_function

# Debug with LD_DEBUG

$ LD_DEBUG=symbols ./program 2>&1 | grep missing_function

2. Useful Debugging Tools

# List all loaded libraries for a running process

$ cat /proc/$(pidof program)/maps | grep "\.so"

# Show library dependencies recursively

$ ldd --verbose program

# Display dynamic section

$ readelf --dynamic program

# Show all symbols in a library

$ nm --dynamic --defined-only libexample.so

# Trace library calls

$ ltrace ./program

# System call trace (includes dlopen calls)

$ strace -e trace=open,openat ./program

Summary

Dynamic linking transforms how we build and deploy software on Linux. By understanding its mechanisms—from the GOT and PLT to symbol versioning—we can create more efficient, maintainable, and flexible applications.

Key Takeaways from Our Exploration

1.

Shared Libraries Dramatically Reduce Disk Usage: We saw a 52× size reduction in our example.

2.

Memory Is Shared Between Processes: Multiple programs use the same library code in RAM.

3.

The PLT and GOT Enable Lazy Binding: Functions are resolved only when first called.

4.

Runtime Loading with dlopen Provides Ultimate Flexibility: Load plug-ins and extensions on demand.

5.

Symbol Versioning Maintains Compatibility: Libraries can evolve without breaking existing programs.

6.

Security Requires Careful Consideration: LD_PRELOAD and library injection are powerful but dangerous.

In Chapter 10, we’ll dive deeper into lazy loading, exploring how the dynamic linker optimizes program startup and manages symbol resolution on demand.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_10

10. Lazy Loading: Dynamic Linking on Demand

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

Imagine starting a complex application like LibreOffice or Firefox. If the program had to load and resolve every single function from every library it might possibly use—spell checkers, image decoders, network protocols, printing systems—the startup time would be unbearable. Yet these applications start in seconds. How? The answer is lazy loading, one of the most elegant optimizations in modern operating systems.

The Problem: Loading Everything Upfront

Let’s create a program that demonstrates why lazy loading is necessary:

// feature_rich.c - A program with many features

#include <stdio.h>

#include <string.h>

#include <math.h>

#include <time.h>

#include <stdlib.h>

// Feature 1: Mathematical operations

void math_feature(void) {

    printf("Computing complex math...\n");

    double result = sin(1.0) * cos(2.0) * tan(3.0) * log(4.0) * sqrt(5.0);

    printf("Result: %f\n", result);

}

// Feature 2: Time operations

void time_feature(void) {

    time_t now = time(NULL);

    printf("Current time: %s", ctime(&now));

}

// Feature 3: Random operations

void random_feature(void) {

    srand(time(NULL));

    printf("Random numbers: ");

    for (int i = 0; i < 5; i++) {

        printf("%d ", rand() % 100);

    }

    printf("\n");

}

// Feature 4: Rarely used error handler

void rare_error_handler(const char* msg) {

    fprintf(stderr, "CRITICAL ERROR: %s\n", msg);

    // Complex error reporting that uses many functions

    char buffer[1024];

    snprintf(buffer, sizeof(buffer), "Error at %s: %s", __TIME__, msg);

    // Would write to log, send network alert, etc.

}

int main(int argc, char* argv[]) {

    printf("Feature-rich application started\n");

    if (argc < 2) {

        printf("Usage: %s [math|time|random]\n", argv[0]);

        return 1;

    }

    if (strcmp(argv[1], "math") == 0) {

        math_feature();

    } else if (strcmp(argv[1], "time") == 0) {

        time_feature();

    } else if (strcmp(argv[1], "random") == 0) {

        random_feature();

    } else {

        rare_error_handler("Unknown feature requested");

    }

    return 0;

}

Compile and examine the symbols:

$ gcc -o feature_rich feature_rich.c -lm

# See all the dynamic symbols this program uses

$ objdump --dynamic-reloc feature_rich | grep -c "R_X86_64_JUMP_SLOT"

16

# List them

$ objdump --dynamic-reloc feature_rich | grep "R_X86_64_JUMP_SLOT"

0000000000404018 R_X86_64_JUMP_SLOT  free@GLIBC_2.2.5

0000000000404020 R_X86_64_JUMP_SLOT  puts@GLIBC_2.2.5

0000000000404028 R_X86_64_JUMP_SLOT  srand@GLIBC_2.2.5

0000000000404030 R_X86_64_JUMP_SLOT  time@GLIBC_2.2.5

0000000000404038 R_X86_64_JUMP_SLOT  log@GLIBC_2.2.5

0000000000404040 R_X86_64_JUMP_SLOT  printf@GLIBC_2.2.5

0000000000404048 R_X86_64_JUMP_SLOT  snprintf@GLIBC_2.2.5

0000000000404050 R_X86_64_JUMP_SLOT  fprintf@GLIBC_2.2.5

0000000000404058 R_X86_64_JUMP_SLOT  strcmp@GLIBC_2.2.5

0000000000404060 R_X86_64_JUMP_SLOT  cos@GLIBC_2.2.5

0000000000404068 R_X86_64_JUMP_SLOT  sqrt@GLIBC_2.2.5

0000000000404070 R_X86_64_JUMP_SLOT  tan@GLIBC_2.2.5

0000000000404078 R_X86_64_JUMP_SLOT  ctime@GLIBC_2.2.5

0000000000404080 R_X86_64_JUMP_SLOT  rand@GLIBC_2.2.5

0000000000404088 R_X86_64_JUMP_SLOT  sin@GLIBC_2.2.5

If we only run the “time” feature, why should we resolve math functions like sin, cos, and tan? This is where lazy loading shines. Without lazy loading, every single symbol would need to be resolved before main() could run. The dynamic linker would have to find sin in libm, cos in libm, tan in libm—and that’s just the obvious ones. Think about error handlers used only in edge cases or debugging functions never called in production. Resolving every symbol upfront creates unnecessary startup latency. With lazy loading, sin, cos, and tan aren’t resolved until (and only if) you actually call them. For the “time” feature, those symbols never get resolved at all, saving time on startup.

Understanding the PLT and GOT

Before diving into lazy loading, let’s understand the two key data structures that make it possible.

The PLT (Procedure Linkage Table)

The PLT contains small code stubs for each external function:

$ objdump --disassemble --section=.plt feature_rich

Disassembly of section .plt:

0000000000401020 <.plt>:

  401020:       ff 35 e2 2f 00 00       push   0x2fe2(%rip)  # 404008

  401026:       ff 25 e4 2f 00 00       jmp    *0x2fe4(%rip) # 404010

  40102c:       0f 1f 40 00             nopl   0x0(%rax)

0000000000401030 <free@plt>:

  401030:       ff 25 e2 2f 00 00       jmp    *0x2fe2(%rip) # 404018

  401036:       68 00 00 00 00          push   $0x0

  40103b:       e9 e0 ff ff ff          jmp    401020 <.plt>

0000000000401040 <puts@plt>:

  401040:       ff 25 da 2f 00 00       jmp    *0x2fda(%rip) # 404020

  401046:       68 01 00 00 00          push   $0x1

  40104b:       e9 d0 ff ff ff          jmp    401020 <.plt>

Each PLT entry

1.

Jumps through the GOT (Global Offset Table)

2.

If not resolved, pushes an identifier

3.

Jumps to the PLT resolver

The GOT (Global Offset Table)

Initially, GOT entries point back to the PLT:

$ objdump --section=.got.plt --full-contents feature_rich

Contents of section .got.plt:

 404000 003e4000 00000000 00000000 00000000  .>@.............

 404010 00000000 00000000 36104000 00000000  ........6.@.....

 404020 46104000 00000000 56104000 00000000  F.@.....V.@.....

Observing Lazy Loading in Action

Let’s create a program to watch lazy loading happen:

// lazy_demo.c - Demonstrate lazy symbol resolution

#define _GNU_SOURCE

#include <stdio.h>

#include <dlfcn.h>

#include <link.h>

#include <string.h>

// Functions we'll call conditionally

extern double sin(double);

extern double cos(double);

extern int rand(void);

// Helper to print function address

void print_func_info(const char* name, void* func_ptr) {

    Dl_info info;

    if (dladdr(func_ptr, &info)) {

        printf("%-10s: %p -> ", name, func_ptr);

        if (info.dli_saddr) {

            printf("%p in %s\n", info.dli_saddr, info.dli_fname);

        } else {

            printf("NOT RESOLVED YET\n");

        }

    }

}

// Callback for dl_iterate_phdr

static int callback(struct dl_phdr_info *info, size_t size, void *data) {

    if (strstr(info->dlpi_name, "libm.so")) {

        printf("Math library loaded at: %p\n", (void*)info->dlpi_addr);

    }

    return 0;

}

int main(int argc, char* argv[]) {

    printf("=== Lazy Loading Demonstration ===\n\n");

    // Check what libraries are loaded

    printf("Libraries loaded at startup:\n");

    dl_iterate_phdr(callback, NULL);

    // Get function pointers (through PLT)

    void* sin_ptr = (void*)&sin;

    void* cos_ptr = (void*)&cos;

    void* rand_ptr = (void*)&rand;

    printf("\n--- Before calling any functions ---\n");

    print_func_info("sin", sin_ptr);

    print_func_info("cos", cos_ptr);

    print_func_info("rand", rand_ptr);

    // Call one function

    printf("\n--- Calling sin(1.0) ---\n");

    double result = sin(1.0);

    printf("Result: %f\n", result);

    printf("\n--- After calling sin() ---\n");

    print_func_info("sin", sin_ptr);

    print_func_info("cos", cos_ptr);  // Still not resolved!

    print_func_info("rand", rand_ptr); // Still not resolved!

    if (argc > 1) {

        printf("\n--- Calling cos(1.0) ---\n");

        result = cos(1.0);

        printf("Result: %f\n", result);

        printf("\n--- After calling cos() ---\n");

        print_func_info("cos", cos_ptr); // Now resolved

    }

    return 0;

}

Compile and run:

$ gcc -o lazy_demo lazy_demo.c -lm –ldl

Here, -lm links against the math library (libm) for trigonometric and other mathematical functions, while -ldl links against the dynamic loader library (libdl), which provides APIs like dladdr() for querying information about dynamically loaded symbols and libraries—essential for inspecting the lazy resolution process in this demo.

# Run without arguments

$ ./lazy_demo

=== Lazy Loading Demonstration ===

Libraries loaded at startup:

Math library loaded at: 0x7f5e5c600000

--- Before calling any functions ---

sin       : 0x401060 -> NOT RESOLVED YET

cos       : 0x401070 -> NOT RESOLVED YET

rand      : 0x401050 -> NOT RESOLVED YET

--- Calling sin(1.0) ---

Result: 0.841471

--- After calling sin() ---

sin       : 0x401060 -> 0x7f5e5c635b20 in /lib/x86_64-linux-gnu/libm.so.6

cos       : 0x401070 -> NOT RESOLVED YET

rand      : 0x401050 -> NOT RESOLVED YET

Notice how only sin was resolved after we called it!

This output demonstrates the core lazy loading insight: those function pointers shown as “NOT RESOLVED YET” are actually pointing to PLT stubs. The dladdr function reports them as unresolved because they’re not yet pointing to the real library functions. After we call sin(1.0), dladdr can find it because the PLT stub has been invoked, triggering resolution, which patched the GOT entry to point to the real function. cos and rand remain “NOT RESOLVED” because we never called them. On a system where rand is never called, the dynamic linker literally never has to look it up.

Tracing the Resolution Process

Let’s use the dynamic linker’s debug capabilities to see exactly what happens:

# Trace symbol binding

$ LD_DEBUG=bindings ./lazy_demo 2>&1 | grep -E "sin|cos|rand"

     12345:    binding file ./lazy_demo [0] to /lib/x86_64-linux-gnu/libm.so.6 [0]: normal symbol `sin' [GLIBC_2.2.5]

# Trace relocation processing

$ LD_DEBUG=reloc ./lazy_demo 2>&1 | grep -A2 "relocation processing"

     12345:    relocation processing: /lib/x86_64-linux-gnu/libm.so.6 (lazy)

     12345:    relocation processing: /lib/x86_64-linux-gnu/libc.so.6 (lazy)

     12345:    relocation processing: ./lazy_demo (lazy)

The “(lazy)” notation confirms lazy relocation processing!

Controlling Lazy Loading Behavior

Forcing Immediate Binding

Sometimes, we want to disable lazy loading:

// immediate_demo.c

#include <stdio.h>

#include <time.h>

#include <math.h>

void print_timing(const char* msg, clock_t start, clock_t end) {

    double cpu_time = ((double)(end - start)) / CLOCKS_PER_SEC * 1000;

    printf("%s: %.3f ms\n", msg, cpu_time);

}

int main(void) {

    clock_t start = clock();

    // First call to any library function

    printf("Starting program...\n");

    clock_t after_first = clock();

    // Call various math functions

    double x = sin(1.0) + cos(2.0) + tan(3.0);

    clock_t after_math = clock();

    print_timing("First library call", start, after_first);

    print_timing("Math functions", after_first, after_math);

    return 0;

}

Compare lazy vs. immediate binding:

$ gcc -o immediate_demo immediate_demo.c -lm

# With lazy binding (default)

$ ./immediate_demo

Starting program...

First library call: 0.052 ms

Math functions: 0.018 ms

# With immediate binding

$ LD_BIND_NOW=1 ./immediate_demo

Starting program...

First library call: 0.124 ms  # Slower startup!

Math functions: 0.006 ms       # But faster execution

When you set LD_BIND_NOW=1, you’re telling the dynamic linker “resolve all symbols before returning from the loader, not on demand.” This means before your main() function even runs, the linker walks through every PLT entry and resolves each one.

That setup work has to happen somewhere, so lazy loading defers it to first use (paying the cost in small chunks distributed through execution), while eager loading concentrates it at startup.

For most programs, lazy loading wins: you pay the cost of resolving the symbols you actually use, and never pay for unused functions. For a few programs—real-time systems where pauses are forbidden, or security-critical code where you want to catch resolution errors early—eager loading’s predictable cost at startup is worth the penalty. This is one of those design trade-offs where there’s no universal winner; you choose based on your requirements.

Using dlopen with Different Flags

// dlopen_lazy.c - Comparing RTLD_LAZY vs RTLD_NOW

#include <stdio.h>

#include <dlfcn.h>

#include <time.h>

typedef double (*math_func)(double);

void test_loading(const char* lib_path, int flags, const char* mode) {

    clock_t start = clock();

    // Open library

    void* handle = dlopen(lib_path, flags);

    if (!handle) {

        fprintf(stderr, "dlopen failed: %s\n", dlerror());

        return;

    }

    clock_t after_open = clock();

    // Get symbol

    math_func sin_func = (math_func)dlsym(handle, "sin");

    clock_t after_sym = clock();

    // First call

    double result = sin_func(1.0);

    clock_t after_call = clock();

    // Report timings

    printf("\n%s loading:\n", mode);

    printf("  dlopen:     %.3f ms\n",

           ((double)(after_open - start)) / CLOCKS_PER_SEC * 1000);

    printf("  dlsym:      %.3f ms\n",

           ((double)(after_sym - after_open)) / CLOCKS_PER_SEC * 1000);

    printf("  first call: %.3f ms\n",

           ((double)(after_call - after_sym)) / CLOCKS_PER_SEC * 1000);

    printf("  result:     %f\n", result);

    dlclose(handle);

}

int main(void) {

    const char* libm = "libm.so.6";

    printf("=== Comparing RTLD_LAZY vs RTLD_NOW ===\n");

    test_loading(libm, RTLD_LAZY, "Lazy");

    test_loading(libm, RTLD_NOW, "Immediate");

    return 0;

}

Real-World Application: Plug-In Systems

Let’s build a plug-in system that leverages lazy loading:

// plugin_host.c - A program that loads plugins on demand

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#include <dlfcn.h>

#include <dirent.h>

// Plugin interface

typedef struct {

    const char* (*get_name)(void);

    const char* (*get_version)(void);

    void (*execute)(const char* args);

} Plugin;

typedef struct {

    void* handle;

    Plugin* interface;

    char* filename;

} LoadedPlugin;

typedef struct {

    LoadedPlugin* plugins;

    size_t count;

    size_t capacity;

} PluginManager;

// Initialize plugin manager

PluginManager* plugin_manager_new(void) {

    PluginManager* pm = malloc(sizeof(PluginManager));

    pm->plugins = NULL;

    pm->count = 0;

    pm->capacity = 0;

    return pm;

}

// Load a plugin (but don't resolve symbols yet!)

int plugin_manager_register(PluginManager* pm, const char* filename) {

    // Just store the filename, don't load yet

    if (pm->count >= pm->capacity) {

        pm->capacity = pm->capacity ? pm->capacity * 2 : 4;

        pm->plugins = realloc(pm->plugins,pm->capacity * sizeof(LoadedPlugin));

    }

    pm->plugins[pm->count].handle = NULL;

    pm->plugins[pm->count].interface = NULL;

    pm->plugins[pm->count].filename = strdup(filename);

    pm->count++;

    printf("Registered plugin: %s (not loaded yet)\n", filename);

    return 0;

}

// Actually load a plugin when needed

Plugin* plugin_manager_load(PluginManager* pm, size_t index) {

    if (index >= pm->count) return NULL;

    LoadedPlugin* lp = &pm->plugins[index];

    // Already loaded?

    if (lp->interface) return lp->interface;

    printf("Lazy loading plugin: %s\n", lp->filename);

    // Open with RTLD_LAZY for maximum laziness!

    lp->handle = dlopen(lp->filename, RTLD_LAZY | RTLD_LOCAL);

    if (!lp->handle) {

        fprintf(stderr, "Failed to load %s: %s\n",

                lp->filename, dlerror());

        return NULL;

    }

    // Get the plugin interface

    Plugin* (*get_plugin)(void) = dlsym(lp->handle, "get_plugin");

    if (!get_plugin) {

        fprintf(stderr, "Invalid plugin %s: %s\n",

                lp->filename, dlerror());

        dlclose(lp->handle);

        lp->handle = NULL;

        return NULL;

    }

    lp->interface = get_plugin();

    return lp->interface;

}

// Example plugin

// plugin_example.c - compile with: gcc -shared -fPIC -o plugin_example.so plugin_example.c

/*

static const char* example_get_name(void) {

    return "Example Plugin";

}

static const char* example_get_version(void) {

    return "1.0.0";

}

static void example_execute(const char* args) {

    printf("Example plugin executing with args: %s\n", args);

}

static Plugin example_plugin = {

    .get_name = example_get_name,

    .get_version = example_get_version,

    .execute = example_execute

};

Plugin* get_plugin(void) {

    return &example_plugin;

}

*/

int main(void) {

    PluginManager* pm = plugin_manager_new();

    // Scan for plugins (but don't load them)

    printf("=== Scanning for plugins ===\n");

    DIR* dir = opendir("./plugins");

    if (dir) {

        struct dirent* entry;

        while ((entry = readdir(dir))) {

            if (strstr(entry->d_name, ".so")) {

                char path[256];

                snprintf(path, sizeof(path), "./plugins/%s", entry->d_name);

                plugin_manager_register(pm, path);

            }

        }

        closedir(dir);

    }

    printf("\n=== Interactive Plugin System ===\n");

    printf("Commands: list, info <n>, run <n> <args>, quit\n\n");

    char line[256];

    while (fgets(line, sizeof(line), stdin)) {

        char cmd[32], args[224];

        int n;

        if (sscanf(line, "%31s", cmd) != 1) continue;

        if (strcmp(cmd, "quit") == 0) {

            break;

        } else if (strcmp(cmd, "list") == 0) {

            for (size_t i = 0; i < pm->count; i++) {

                printf("%zu: %s %s\n", i, pm->plugins[i].filename,

                       pm->plugins[i].interface ? "(loaded)" : "(not loaded)");

            }

        } else if (sscanf(line, "info %d", &n) == 1) {

            Plugin* plugin = plugin_manager_load(pm, n);

            if (plugin) {

                printf("Name: %s\n", plugin->get_name());

                printf("Version: %s\n", plugin->get_version());

            }

        } else if (sscanf(line, "run %d %223[^\n]", &n, args) >= 1) {

            Plugin* plugin = plugin_manager_load(pm, n);

            if (plugin) {

                plugin->execute(args);

            }

        }

    }

    return 0;

}

This plug-in system demonstrates several benefits of lazy loading:

1.

Fast Startup: Plug-ins aren’t loaded until needed. Your host application starts instantly, even with hundreds of plug-ins available.

2.

Memory Efficiency: Unused plug-ins consume no memory. If a user never uses the video codec plug-in, the codec library is never loaded, freeing up RAM for other uses.

3.

Fault Isolation: If a plug-in has a bug or crashes, the host application might survive if the crash happens during plug-in execution. If every plug-in was eagerly loaded during startup, a single bad plug-in could prevent the entire application from starting.

4.

Late Binding: Users can add plug-ins by just dropping .so files into the plug-ins directory; the host application doesn’t need to be recompiled or even restarted.

This is why mature applications use plug-in systems: they enable extensibility without the cost of building every possible extension into the base application.

Performance Impact of Lazy Loading

Let’s measure the real impact with a comprehensive benchmark:

// lazy_benchmark.c

#include <stdio.h>

#include <stdlib.h>

#include <time.h>

#include <sys/time.h>

#include <unistd.h>

#include <sys/wait.h>

// Many functions we might or might not use

#include <math.h>

#include <string.h>

#include <ctype.h>

#include <locale.h>

#include <wchar.h>

#include <regex.h>

double get_time_ms(void) {

    struct timeval tv;

    gettimeofday(&tv, NULL);

    return tv.tv_sec * 1000.0 + tv.tv_usec / 1000.0;

}

// Program that uses many libraries but only calls one function

void child_program(int feature) {

    switch (feature) {

        case 0: printf("Result: %f\n", sin(1.0)); break;

        case 1: printf("Length: %zu\n", strlen("test")); break;

        case 2: printf("IsAlpha: %d\n", isalpha('A')); break;

        case 3: setlocale(LC_ALL, ""); break;

        case 4: { regex_t r; regcomp(&r, "test", 0); regfree(&r); } break;

    }

}

double measure_child_startup(int feature, int bind_now) {

    double start = get_time_ms();

    pid_t pid = fork();

    if (pid == 0) {

        // Child process

        if (bind_now) {

            setenv("LD_BIND_NOW", "1", 1);

        }

        child_program(feature);

        _exit(0);

    }

    // Parent waits

    waitpid(pid, NULL, 0);

    return get_time_ms() - start;

}

int main(void) {

    printf("=== Lazy Loading Performance Impact ===\n\n");

    const char* features[] = {

        "Math (sin)", "String (strlen)", "Ctype (isalpha)",

        "Locale", "Regex"

    };

    printf("Measuring startup time for different features...\n");

    printf("(Each measurement is average of 50 runs)\n\n");

    printf("%-15s %10s %10s %10s\n",

           "Feature", "Lazy (ms)", "Eager (ms)", "Difference");

    printf("%-15s %10s %10s %10s\n",

           "-------", "---------", "----------", "----------");

    for (int f = 0; f < 5; f++) {

        double lazy_total = 0, eager_total = 0;

        int runs = 50;

        for (int i = 0; i < runs; i++) {

            lazy_total += measure_child_startup(f, 0);

            eager_total += measure_child_startup(f, 1);

        }

        double lazy_avg = lazy_total / runs;

        double eager_avg = eager_total / runs;

        double diff_pct = ((eager_avg - lazy_avg) / lazy_avg) * 100;

        printf("%-15s %10.2f %10.2f %9.1f%%\n",

               features[f], lazy_avg, eager_avg, diff_pct);

    }

    return 0;

}

Typical results:

Feature           Lazy (ms)  Eager (ms) Difference

-------          ---------  ---------- ----------

Math (sin)           2.14       2.89      35.0%

String (strlen)      1.92       2.71      41.1%

Ctype (isalpha)      1.88       2.68      42.6%

Locale               2.35       3.21      36.6%

Regex                2.51       3.43      36.7%

Eager binding adds 35-42% to startup time!

Security Implications of Lazy Loading

The LD_BIND_NOW Security Pattern

// secure_launcher.c

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

int main(int argc, char* argv[]) {

    // For security-critical applications, resolve all symbols upfront

    setenv("LD_BIND_NOW", "1", 1);

    // Also clear other dangerous variables

    unsetenv("LD_PRELOAD");

    unsetenv("LD_LIBRARY_PATH");

    // Now exec the real program

    if (argc < 2) {

        fprintf(stderr, "Usage: %s <program> [args...]\n", argv[0]);

        return 1;

    }

    execvp(argv[1], argv + 1);

    perror("execvp");

    return 1;

}

Advanced Topics: Profiling Symbol Resolution

// symbol_profiler.c - Profile symbol resolution timing

#define _GNU_SOURCE

#include <stdio.h>

#include <dlfcn.h>

#include <time.h>

#include <string.h>

// Interpose on _dl_runtime_resolve (the lazy resolver)

static void* (*original_dl_resolve)(void*, int) = NULL;

static int resolve_count = 0;

void* _dl_runtime_resolve(void* l, int reloc_index) {

    if (!original_dl_resolve) {

        original_dl_resolve = dlsym(RTLD_NEXT, "_dl_runtime_resolve");

    }

    struct timespec start, end;

    clock_gettime(CLOCK_MONOTONIC, &start);

    void* result = original_dl_resolve(l, reloc_index);

    clock_gettime(CLOCK_MONOTONIC, &end);

    double elapsed = (end.tv_sec - start.tv_sec) * 1000.0 +

                    (end.tv_nsec - start.tv_nsec) / 1000000.0;

    resolve_count++;

    printf("Symbol resolution #%d took %.3f ms\n", resolve_count, elapsed);

    return result;

}

Summary

Lazy loading is a powerful optimization that makes modern applications feasible. By deferring symbol resolution until functions are actually called, it provides

1.

Faster Startup Times: We measured 35-42% improvement.

2.

Reduced Memory Usage: Unused code is never paged in.

3.

Better Fault Isolation: Broken dependencies only matter if used.

4.

Flexibility for Plug-In Systems: Load extensions on demand.

Key Insights from Our Exploration

The PLT and GOT work together to implement lazy binding.

Each function is resolved exactly once, on first call.

LD_BIND_NOW and RTLD_NOW force eager resolution.

Security-critical applications may prefer eager binding.

Plug-in systems benefit greatly from lazy loading.

In Chapter 11, we’ll explore the environment variables that give us fine-grained control over the dynamic linking process, building on our understanding of lazy loading.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_11

11. Taking Control: Environment Variables for Dynamic Linking

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

You’ve spent hours debugging a mysterious crash. The program works on your machine but fails on the production server. After much investigation, you discover the culprit: a different version of a shared library. If only you could have seen which libraries were being loaded and how symbols were being resolved! This is where the dynamic linker’s environment variables become invaluable—they transform the opaque process of dynamic linking into something transparent and controllable.

The Power Hidden in Environment Variables

Let’s start with a program that has a subtle dependency issue:

// version_sensitive.c

#include <stdio.h>

#include <openssl/evp.h>

#include <openssl/opensslv.h>

int main(void) {

    printf("Compiled against OpenSSL: %s\n", OPENSSL_VERSION_TEXT);

    printf("Running with OpenSSL: %s\n", OpenSSL_version(OPENSSL_VERSION));

    // Try to use a function that might not exist in older versions

    EVP_MD_CTX *ctx = EVP_MD_CTX_new();  // Added in OpenSSL 1.1.0

    if (ctx) {

        printf("Successfully created EVP_MD_CTX\n");

        EVP_MD_CTX_free(ctx);

    } else {

        printf("Failed to create EVP_MD_CTX\n");

    }

    return 0;

}

Compile and run:

$ gcc -o version_sensitive version_sensitive.c -lcrypto

$ ./version_sensitive

Compiled against OpenSSL: OpenSSL 1.1.1f  31 Mar 2020

Segmentation fault (core dumped)

What happened? Let’s use environment variables to investigate:

$ LD_DEBUG=libs ./version_sensitive 2>&1 | grep -i ssl

     12345:     find library=libcrypto.so.1.0.0 [0]; searching

     12345:       trying file=/usr/lib/x86_64-linux-gnu/libcrypto.so.1.0.0

Aha! The system has OpenSSL 1.0.0, but we compiled against 1.1.1. Let’s see exactly what’s happening:

$ LD_DEBUG=symbols ./version_sensitive 2>&1 | grep EVP_MD_CTX_new

     12345:     symbol lookup error: undefined symbol: EVP_MD_CTX_new

LD_LIBRARY_PATH: Controlling Library Search Order

LD_LIBRARY_PATH is probably the most used (and abused) dynamic linker variable. Let’s understand it properly:

// multiversion.c - Test with multiple library versions

#include <stdio.h>

#include <dlfcn.h>

void print_library_info(const char* lib_name) {

    void* handle = dlopen(lib_name, RTLD_LAZY);

    if (!handle) {

        printf("Failed to load %s: %s\n", lib_name, dlerror());

        return;

    }

    // Get the actual path that was loaded

    struct link_map* map;

    if (dlinfo(handle, RTLD_DI_LINKMAP, &map) == 0) {

        printf("Loaded %s from: %s\n", lib_name, map->l_name);

    }

    dlclose(handle);

}

int main(void) {

    printf("=== Library Loading Test ===\n");

    // Print current LD_LIBRARY_PATH

    const char* ld_path = getenv("LD_LIBRARY_PATH");

    printf("LD_LIBRARY_PATH: %s\n\n", ld_path ? ld_path : "(not set)");

    // Try to load some common libraries

    print_library_info("libm.so.6");

    print_library_info("libcrypto.so");

    print_library_info("libcustom.so");  // Likely to fail

    return 0;

}

Let’s create multiple versions of a library to test:

libversion.c - Version 1.0

const char* get_version(void) { return "1.0.0"; }

void feature_one(void) { printf("Feature 1 from v1.0\n"); }

libversion2.c - Version 2.0

const char* get_version(void) { return "2.0.0"; }

void feature_one(void) { printf("Feature 1 from v2.0\n"); }

void feature_two(void) { printf("Feature 2 (new in v2.0)\n"); }

Build the libraries:

$ mkdir -p libs/v1 libs/v2

$ gcc -shared -fPIC -o libs/v1/libversion.so libversion.c

$ gcc -shared -fPIC -o libs/v2/libversion.so libversion2.c

Now, test library path precedence:

# Default behavior - library not found

$ gcc -o test_version test_version.c -lversion

/usr/bin/ld: cannot find -lversion

# Using LD_LIBRARY_PATH

$ gcc -o test_version test_version.c -L./libs/v1 -lversion

$ LD_LIBRARY_PATH=./libs/v1 ./test_version

Version: 1.0.0

# Override with different version

$ LD_LIBRARY_PATH=./libs/v2:./libs/v1 ./test_version

Version: 2.0.0  # v2 takes precedence!

# Show search order

$ LD_DEBUG=libs LD_LIBRARY_PATH=./libs/v2:./libs/v1 ./test_version 2>&1 | grep "search path"

     12345:     search path=./libs/v2:./libs/v1:tls/x86_64:tls:x86_64:     (LD_LIBRARY_PATH)

LD_LIBRARY_PATH

This is perhaps the most commonly used environment variable for dynamic linking. It specifies additional directories where the dynamic linker should look for shared libraries.

# Basic usage

$ export LD_LIBRARY_PATH=/path/to/my/libs:/another/path

$ ./myprogram

# Temporary usage for a single command

$ LD_LIBRARY_PATH=/path/to/my/libs ./myprogram

Let’s create a practical example to demonstrate its use:

// mylib.c

#include <stdio.h>

void print_message(void) {

    printf("This is version 1.0 of the library\n");

}

Compile as shared library:

gcc -shared -fPIC -o libmylib.so mylib.c

// main.c

extern void print_message(void);

int main(void) {

    print_message();

    return 0;

}

Compile with

gcc -o main main.c -L. -lmylib

Now, we can control which version of the library is loaded:

$ mkdir v1 v2

$ cp libmylib.so v1/

$ # Create a different version in v2

$ LD_LIBRARY_PATH=./v1 ./main

This is version 1.0 of the library

$ LD_LIBRARY_PATH=./v2 ./main

This is version 2.0 of the library

LD_LIBRARY_PATH solves a real problem: during development, you’re building libraries and applications simultaneously. You want your application to use your locally built libraries, not system-installed versions. LD_LIBRARY_PATH makes this possible without installing your libraries system-wide—useful when you’re still testing.

However, LD_LIBRARY_PATH is also a security risk. An attacker who can control this variable can force programs to load malicious libraries. This is particularly dangerous for setuid binaries or any elevated-privilege program. The operating system can theoretically protect against this (and some do), but on Linux, it’s primarily the application’s responsibility to sanitize its environment when running with elevated privileges.

Security Warning: The Dangers of LD_LIBRARY_PATH

// privilege_escalation_demo.c

#include <stdio.h>

#include <unistd.h>

#include <stdlib.h>

int main(void) {

    printf("Current privileges:\n");

    printf("Real UID: %d, Effective UID: %d\n", getuid(), geteuid());

    if (geteuid() == 0) {

        printf("WARNING: Running with root privileges!\n");

        // Check for dangerous environment

        if (getenv("LD_LIBRARY_PATH")) {

            printf("SECURITY ALERT: LD_LIBRARY_PATH is set!\n");

            printf("This could allow privilege escalation.\n");

            // Safe programs should clear it

            unsetenv("LD_LIBRARY_PATH");

            unsetenv("LD_PRELOAD");

            printf("Cleared dangerous environment variables.\n");

        }

    }

    // Continue with normal operation...

    system("ls");  // This would use the sanitized environment

    return 0;

}

LD_DEBUG: Your Window into Dynamic Linking

LD_DEBUG is the Swiss Army knife of dynamic linking debugging. It accepts multiple options:

# See all available options

$ LD_DEBUG=help ./myprogram

Valid options for the LD_DEBUG environment variable are:

  libs        display library search paths

  reloc       display relocation processing

  files       display progress for input file

  symbols     display symbol table processing

  bindings    display information about symbol binding

  versions    display version dependencies

  all         all previous options combined

  statistics  display relocation statistics

  unused      determine unused DSOs

  help        display this help message and exit

Different debugging categories reveal different aspects of the dynamic linking process. Let’s understand what each tells us:

libs: Shows where the linker searches for libraries (which directories, in what order, which files it finds).

symbols: Reveals which symbols are being looked up and where they’re found.

bindings: Shows the moment symbols are resolved—which library is providing which symbol.

reloc: Displays relocation processing—how addresses are being adjusted for position independence.

statistics: Gives timing information about how long each phase takes.

all: Combine all of these for comprehensive visibility.

This information is invaluable when debugging “why can’t my program find that library?” or “why is my program crashing with an undefined symbol error?” By analyzing LD_DEBUG output, you can see exactly what the dynamic linker is doing at each step.

LD_PRELOAD: The Power and Peril of Function Interposition

This powerful (and potentially dangerous) variable allows us to intercept library calls by preloading our own libraries. It’s commonly used for

Debugging

Profiling

Security monitoring

Function interception

When you set LD_PRELOAD, the dynamic linker loads that library before all others, and symbol searches consult it first. This means your functions get called instead of the standard ones—you’ve interposed yourself into the call chain. This enables powerful patterns like wrapping malloc() to track allocations, intercepting open() to log file access, or replacing rand() for testing purposes.

Let’s create a practical example of function interception:

// malloc_profiler.c - Track memory allocations

#define _GNU_SOURCE

#include <stdio.h>

#include <stdlib.h>

#include <dlfcn.h>

#include <pthread.h>

typedef struct {

    size_t count;

    size_t total_size;

    size_t current_size;

    size_t peak_size;

} AllocationStats;

static AllocationStats stats = {0};

static pthread_mutex_t stats_mutex = PTHREAD_MUTEX_INITIALIZER;

static void* (*real_malloc)(size_t) = NULL;

static void (*real_free)(void*) = NULL;

__attribute__((constructor))

static void init(void) {

    real_malloc = dlsym(RTLD_NEXT, "malloc");

    real_free = dlsym(RTLD_NEXT, "free");

}

__attribute__((destructor))

static void fini(void) {

    fprintf(stderr, "\n=== Memory Allocation Report ===\n");

    fprintf(stderr, "Total allocations: %zu\n", stats.count);

    fprintf(stderr, "Total bytes allocated: %zu\n", stats.total_size);

    fprintf(stderr, "Peak memory usage: %zu bytes\n", stats.peak_size);

    fprintf(stderr, "Leaked memory: %zu bytes\n", stats.current_size);

}

void* malloc(size_t size) {

    void* ptr = real_malloc(size + sizeof(size_t));

    if (ptr) {

        *(size_t*)ptr = size;

        pthread_mutex_lock(&stats_mutex);

        stats.count++;

        stats.total_size += size;

        stats.current_size += size;

        if (stats.current_size > stats.peak_size) {

            stats.peak_size = stats.current_size;

        }

        pthread_mutex_unlock(&stats_mutex);

        return (char*)ptr + sizeof(size_t);

    }

    return NULL;

}

void free(void* ptr) {

    if (ptr) {

        void* real_ptr = (char*)ptr - sizeof(size_t);

        size_t size = *(size_t*)real_ptr;

        pthread_mutex_lock(&stats_mutex);

        stats.current_size -= size;

        pthread_mutex_unlock(&stats_mutex);

        real_free(real_ptr);

    }

}

Build and use:

$ gcc -shared -fPIC -o malloc_profiler.so malloc_profiler.c -ldl -lpthread

# Profile any program

$ LD_PRELOAD=./malloc_profiler.so ls /usr/bin | head

bin1

bin2

...

=== Memory Allocation Report ===

Total allocations: 142

Total bytes allocated: 28976

Peak memory usage: 15234 bytes

Leaked memory: 0 bytes

Example 2: Security Monitoring

// security_monitor.c - Monitor file access

#define _GNU_SOURCE

#include <stdio.h>

#include <dlfcn.h>

#include <fcntl.h>

#include <stdarg.h>

#include <time.h>

static int (*real_open)(const char*, int, ...) = NULL;

static FILE* (*real_fopen)(const char*, const char*) = NULL;

__attribute__((constructor))

static void init(void) {

    real_open = dlsym(RTLD_NEXT, "open");

    real_fopen = dlsym(RTLD_NEXT, "fopen");

}

int open(const char* pathname, int flags, ...) {

    // Log the access

    time_t now = time(NULL);

    fprintf(stderr, "[%s] OPEN: %s (flags: 0x%x)\n",

            ctime(&now), pathname, flags);

    // Handle optional mode parameter

    if (flags & O_CREAT) {

        va_list args;

        va_start(args, flags);

        mode_t mode = va_arg(args, mode_t);

        va_end(args);

        return real_open(pathname, flags, mode);

    }

    return real_open(pathname, flags);

}

FILE* fopen(const char* pathname, const char* mode) {

    time_t now = time(NULL);

    fprintf(stderr, "[%s] FOPEN: %s (mode: %s)\n",

            ctime(&now), pathname, mode);

    return real_fopen(pathname, mode);

}

Example 3: Testing with Mock Functions

// mock_time.c - Control time for testing

#define _GNU_SOURCE

#include <time.h>

#include <stdlib.h>

#include <string.h>

static time_t mock_time = 0;

static int use_mock = 0;

__attribute__((constructor))

static void init(void) {

    const char* mock_time_str = getenv("MOCK_TIME");

    if (mock_time_str) {

        mock_time = atol(mock_time_str);

        use_mock = 1;

    }

}

time_t time(time_t* tloc) {

    time_t result = use_mock ? mock_time : ((time_t)-1);

    if (tloc) *tloc = result;

    return result;

}

LD_BIND_NOW: Trading Startup Time for Predictability

We explored lazy loading in Chapter 10. LD_BIND_NOW gives us control:

// bind_timing.c - Measure binding impact

#include <stdio.h>

#include <time.h>

#include <math.h>

#include <string.h>

#include <ctype.h>

#include <locale.h>

#define TIME_OPERATION(op, desc) do { \

    clock_t start = clock(); \

    op; \

    clock_t end = clock(); \

    printf("%s: %.3f ms\n", desc, \

           ((double)(end - start)) / CLOCKS_PER_SEC * 1000); \

} while(0)

int main(void) {

    printf("=== Symbol Binding Timing Test ===\n");

    printf("LD_BIND_NOW: %s\n\n", getenv("LD_BIND_NOW") ? "SET" : "NOT SET");

    TIME_OPERATION(

        printf("First printf\n"),

        "First printf call"

    );

    TIME_OPERATION(

        sin(1.0),

        "First sin() call"

    );

    TIME_OPERATION(

        strlen("test"),

        "First strlen() call"

    );

    TIME_OPERATION(

        setlocale(LC_ALL, ""),

        "First setlocale() call"

    );

    // Second calls should be fast regardless

    TIME_OPERATION(

        sin(2.0),

        "Second sin() call"

    );

    return 0;

}

Compare the results:

$ gcc -o bind_timing bind_timing.c -lm

# Lazy binding (default)

$ ./bind_timing

=== Symbol Binding Timing Test ===

LD_BIND_NOW: NOT SET

First printf call: 0.125 ms

First sin() call: 0.089 ms

First strlen() call: 0.023 ms

First setlocale() call: 0.234 ms

Second sin() call: 0.001 ms

# Immediate binding

$ LD_BIND_NOW=1 ./bind_timing

=== Symbol Binding Timing Test ===

LD_BIND_NOW: SET

First printf call: 0.003 ms  # Much faster!

First sin() call: 0.001 ms

First strlen() call: 0.001 ms

First setlocale() call: 0.156 ms

Second sin() call: 0.001 ms

Advanced Environment Variables

LD_SHOW_AUXV: Understanding Process Initialization

// auxv_demo.c - Demonstrate auxiliary vector usage

#include <stdio.h>

#include <elf.h>

#include <sys/auxv.h>

int main(void) {

    printf("=== Process Auxiliary Vector Info ===\n");

    // Get values from auxiliary vector

    printf("Page size: %ld bytes\n", getauxval(AT_PAGESZ));

    printf("Clock ticks/sec: %ld\n", getauxval(AT_CLKTCK));

    printf("Platform: %s\n", (char*)getauxval(AT_PLATFORM));

    printf("UID: %ld\n", getauxval(AT_UID));

    printf("GID: %ld\n", getauxval(AT_GID));

    printf("Secure mode: %ld\n", getauxval(AT_SECURE));

    return 0;

}

Run with LD_SHOW_AUXV:

$ LD_SHOW_AUXV=1 ./auxv_demo

AT_SYSINFO_EHDR: 0x7fff12345000

AT_HWCAP:        0xbfebfbff

AT_PAGESZ:       4096

AT_CLKTCK:       100

AT_PHDR:         0x400040

AT_PHENT:        56

AT_PHNUM:        9

AT_BASE:         0x7f1234567000

AT_FLAGS:        0x0

AT_ENTRY:        0x401020

AT_UID:          1000

AT_EUID:         1000

AT_GID:          1000

AT_EGID:         1000

AT_SECURE:       0

AT_RANDOM:       0x7fff12345678

AT_PLATFORM:     x86_64

=== Process Auxiliary Vector Info ===

Page size: 4096 bytes

LD_TRACE_LOADED_OBJECTS: Alternative to ldd

Using LD_TRACE_LOADED_OBJECTS

$ LD_TRACE_LOADED_OBJECTS=1 ./myprogram

        linux-vdso.so.1 (0x00007fff123000)

        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f123000)

        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f456000)

This is exactly what ldd does internally!

$ strings $(which ldd) | grep LD_TRACE

LD_TRACE_LOADED_OBJECTS=1

LD_DYNAMIC_WEAK: Controlling Weak Symbol Behavior

// weak_behavior.c

#include <stdio.h>

__attribute__((weak)) void optional_function(void) {

    printf("Weak implementation\n");

}

int main(void) {

    printf("LD_DYNAMIC_WEAK: %s\n",

           getenv("LD_DYNAMIC_WEAK") ? "SET" : "NOT SET");

    if (optional_function) {

        optional_function();

    } else {

        printf("optional_function not available\n");

    }

    return 0;

}

Creating a Comprehensive Dynamic Linking Debugger

Let’s put it all together in a tool that uses multiple environment variables:

#!/bin/bash

# ld_analyzer.sh - Comprehensive dynamic linking analyzer

usage() {

    echo "Usage: $0 [options] <program> [args...]"

    echo "Options:"

    echo "  -l    Show library loading paths"

    echo "  -s    Show symbol binding"

    echo "  -r    Show relocation processing"

    echo "  -S    Show statistics"

    echo "  -p    Profile with immediate binding"

    echo "  -t    Trace loaded objects only"

    echo "  -a    Show auxiliary vector"

    echo "  -v    Verbose (all debug output)"

    exit 1

}

# Parse options

DEBUG_OPTS=""

while getopts "lsrSptavh" opt; do

    case $opt in

        l) DEBUG_OPTS="libs" ;;

        s) DEBUG_OPTS="symbols" ;;

        r) DEBUG_OPTS="reloc" ;;

        S) DEBUG_OPTS="statistics" ;;

        p) export LD_BIND_NOW=1; echo "Enabled immediate binding" ;;

        t) export LD_TRACE_LOADED_OBJECTS=1; shift; exec "$@" ;;

        a) export LD_SHOW_AUXV=1; echo "Showing auxiliary vector" ;;

        v) DEBUG_OPTS="all" ;;

        h|*) usage ;;

    esac

done

shift $((OPTIND-1))

if [ $# -eq 0 ]; then

    usage

fi

# Set up debug environment

if [ -n "$DEBUG_OPTS" ]; then

    export LD_DEBUG=$DEBUG_OPTS

fi

# Create output directory

OUTPUT_DIR="ld_analysis_$(date +%Y%m%d_%H%M%S)"

mkdir -p "$OUTPUT_DIR"

echo "Running: $@"

echo "Output directory: $OUTPUT_DIR"

echo "================================="

# Run program and capture output

"$@" 2>&1 | tee "$OUTPUT_DIR/output.log"

# Post-process if statistics were requested

if [ "$DEBUG_OPTS" = "statistics" ]; then

    echo -e "\n=== Summary Statistics ==="

    grep "number of relocations:" "$OUTPUT_DIR/output.log"

    grep "startup time:" "$OUTPUT_DIR/output.log"

fi

Security Best Practices

Protecting Against Library Injection

// secure_app.c - Security-conscious application

#define _GNU_SOURCE

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <string.h>

#include <link.h>

// Check for suspicious libraries

static int check_library(struct dl_phdr_info *info, size_t size, void *data) {

    // Skip the main program and ld.so

    if (strlen(info->dlpi_name) == 0 ||

        strstr(info->dlpi_name, "ld-linux")) {

        return 0;

    }

    // Check for suspicious library names

    if (strstr(info->dlpi_name, "hack") ||

        strstr(info->dlpi_name, "evil") ||

        strstr(info->dlpi_name, "LD_PRELOAD")) {

        fprintf(stderr, "SECURITY ALERT: Suspicious library loaded: %s\n",

                info->dlpi_name);

        *(int*)data = 1;  // Set alert flag

    }

    return 0;

}

int main(void) {

    // Check if running with elevated privileges

    if (geteuid() != getuid() || getegid() != getgid()) {

        printf("Running with elevated privileges - checking security\n");

        // Clear dangerous environment variables

        char* dangerous_vars[] = {

            "LD_PRELOAD", "LD_LIBRARY_PATH", "LD_AUDIT",

            "LD_DEBUG", "LD_DEBUG_OUTPUT", NULL

        };

        for (int i = 0; dangerous_vars[i]; i++) {

            if (getenv(dangerous_vars[i])) {

                printf("Clearing %s\n", dangerous_vars[i]);

                unsetenv(dangerous_vars[i]);

            }

        }

        // Check loaded libraries

        int alert = 0;

        dl_iterate_phdr(check_library, &alert);

        if (alert) {

            fprintf(stderr, "Security check failed - exiting\n");

            exit(1);

        }

    }

    printf("Security checks passed - continuing\n");

    // Normal application code...

    return 0;

}

Performance Optimization Patterns

Dynamic Library Path Caching

// path_cache.c - Cache library paths for performance

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#include <dlfcn.h>

typedef struct {

    char* name;

    char* path;

    void* handle;

} LibraryCache;

static LibraryCache* cache = NULL;

static size_t cache_size = 0;

static size_t cache_capacity = 0;

void* cached_dlopen(const char* name) {

    // Check cache first

    for (size_t i = 0; i < cache_size; i++) {

        if (strcmp(cache[i].name, name) == 0) {

            printf("Cache hit for %s\n", name);

            return cache[i].handle;

        }

    }

    // Not in cache - load it

    printf("Cache miss for %s - loading\n", name);

    // Try multiple paths

    const char* paths[] = {

        getenv("LD_LIBRARY_PATH"),

        "/usr/local/lib",

        "/usr/lib",

        "/lib",

        ".",

        NULL

    };

    void* handle = NULL;

    char full_path[1024];

    for (int i = 0; paths[i] && !handle; i++) {

        if (!paths[i]) continue;

        snprintf(full_path, sizeof(full_path), "%s/%s", paths[i], name);

        handle = dlopen(full_path, RTLD_LAZY);

        if (handle) {

            // Add to cache

            if (cache_size >= cache_capacity) {

                cache_capacity = cache_capacity ? cache_capacity * 2 : 8;

                cache = realloc(cache, cache_capacity * sizeof(LibraryCache));

            }

            cache[cache_size].name = strdup(name);

            cache[cache_size].path = strdup(full_path);

            cache[cache_size].handle = handle;

            cache_size++;

            printf("Loaded %s from %s\n", name, full_path);

        }

    }

    return handle;

}

Summary

Environment variables transform the dynamic linker from a black box into a transparent, controllable system. Through our exploration, we’ve discovered

1.

LD_LIBRARY_PATH controls library search order but requires careful security consideration.

2.

LD_DEBUG provides unprecedented visibility into the linking process.

3.

LD_PRELOAD enables powerful function interposition for debugging and monitoring.

4.

LD_BIND_NOW trades startup time for predictability and security.

5.

Security-critical applications must sanitize their environment.

Key Insights from Real-World Usage

Use LD_DEBUG=libs to diagnose “library not found” errors.

Apply LD_DEBUG=symbols to understand symbol resolution failures.

Employ LD_PRELOAD for debugging, but never in production without careful review.

Set LD_BIND_NOW=1 for security-critical applications.

Always clear environment variables when handling sensitive data.

The power of these variables extends far beyond debugging—they enable testing, profiling, security monitoring, and even hot-patching of running systems. However, with great power comes great responsibility: these same features can be exploited for malicious purposes if not properly controlled.

In Chapter 12, we’ll get into the kernel’s role in loading executables, seeing how these environment variables ultimately influence the kernel’s behavior during program execution.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_12

12. Understanding Linux Executable Loading: A Deep Dive into Kernel Internals

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

When you type ./myprogram and press Enter, a complex dance begins between your shell, the kernel, and the dynamic linker. In milliseconds, the kernel must validate your program, map it into memory, set up a new process, and hand control to your code. This seemingly simple action involves multiple subsystems, security checks, and clever optimizations that have evolved over decades. Let’s pull back the curtain and see exactly how Linux breathes life into your programs.

The Journey from Command to Execution

Let’s start by tracing what happens when you run a program:

// simple_program.c

#include <stdio.h>

#include <unistd.h>

int main(int argc, char *argv[]) {

    printf("PID: %d\n", getpid());

    printf("Program: %s\n", argv[0]);

    printf("Arguments: %d\n", argc);

    for (int i = 0; i < argc; i++) {

        printf("  argv[%d]: %s\n", i, argv[i]);

    }

    return 42;  // Distinctive exit code

}

Compile and trace its execution:

$ gcc -o simple_program simple_program.c

# Trace system calls during execution

$ strace -e trace=execve,openat,mmap,arch_prctl ./simple_program hello world

execve("./simple_program", ["./simple_program", "hello", "world"], 0x7fff...) = 0

arch_prctl(ARCH_SET_FS, 0x7ffff7fb2540) = 0

mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ffff7fc5000

openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3

mmap(NULL, 29523, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7ffff7fbd000

openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3

mmap(NULL, 2037344, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ffff7c00000

mmap(0x7ffff7c28000, 1540096, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0) = 0x7ffff7c28000

PID: 12345

Program: ./simple_program

Arguments: 3

  argv[0]: ./simple_program

  argv[1]: hello

  argv[2]: world

+++ exited with 42 +++

Each line tells a story about the loading process. Let’s understand what’s happening.

The first line, execve(), is the system call that invokes the kernel. Everything after that happens in two phases: kernel phase (loading the executable, setting up memory) and user phase (the dynamic linker setting up shared libraries).

The arch_prctl() call sets up thread-local storage. The series of openat() and mmap() calls show the dynamic linker opening library files and mapping them into memory. The kernel isn’t directly involved in these—it’s just providing the system calls; the actual file searching and library management is done by the dynamic linker, a userspace program.

The execve System Call: Where It All Begins

The execve system call is the only way to execute a new program in Linux. Let’s see how it works:

// execve_demo.c - Understanding execve behavior

#include <unistd.h>

#include <stdio.h>

#include <errno.h>

#include <string.h>

#include <sys/wait.h>

void demonstrate_execve(void) {

    pid_t pid = fork();

    if (pid == 0) {

        // Child process

        char *args[] = {"echo", "Hello from execve!", NULL};

        char *env[] = {"PATH=/bin:/usr/bin", "USER=demo", NULL};

        printf("Child PID %d: About to execve\n", getpid());

        // This replaces the entire process image

        execve("/bin/echo", args, env);

        // Only reached if execve fails

        perror("execve failed");

        _exit(1);

    } else {

        // Parent process

        int status;

        printf("Parent: Child PID is %d\n", pid);

        waitpid(pid, &status, 0);

        printf("Parent: Child exited with status %d\n", WEXITSTATUS(status));

    }

}

int main(void) {

    printf("=== Demonstrating execve ===\n");

    demonstrate_execve();

    printf("\n=== Direct execve (replaces this process) ===\n");

    printf("My PID: %d\n", getpid());

    printf("Executing /usr/bin/whoami...\n");

    char *args[] = {"whoami", NULL};

    char *env[] = {NULL};

    execve("/usr/bin/whoami", args, env);

    // Never reached unless execve fails

    perror("execve failed");

    return 1;

}

Run and observe:

$ ./execve_demo

=== Demonstrating execve ===

Parent: Child PID is 12346

Child PID 12346: About to execve

Hello from execve!

Parent: Child exited with status 0

=== Direct execve (replaces this process) ===

My PID: 12345

Executing /usr/bin/whoami...

username

Inside the Kernel: How execve Works

Let’s trace the kernel’s execution path. While we can’t directly modify the kernel from userspace, we can observe its behavior:

// kernel_loader_trace.c - Observe kernel loading behavior

#define _GNU_SOURCE

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <fcntl.h>

#include <sys/stat.h>

#include <sys/mman.h>

#include <elf.h>

#include <string.h>

void analyze_elf_file(const char *filename) {

    int fd = open(filename, O_RDONLY);

    if (fd < 0) {

        perror("open");

        return;

    }

    struct stat st;

    fstat(fd, &st);

    // Map the file to examine it (like the kernel does)

    void *map = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);

    if (map == MAP_FAILED) {

        perror("mmap");

        close(fd);

        return;

    }

    // Check ELF header (kernel does this first)

    Elf64_Ehdr *ehdr = (Elf64_Ehdr *)map;

    printf("=== ELF File Analysis (What the Kernel Sees) ===\n");

    // Verify magic number

    if (memcmp(ehdr->e_ident, ELFMAG, SELFMAG) != 0) {

        printf("Not an ELF file!\n");

        goto cleanup;

    }

    printf("ELF Magic: %02x %02x %02x %02x\n",

           ehdr->e_ident[0], ehdr->e_ident[1],

           ehdr->e_ident[2], ehdr->e_ident[3]);

    printf("Type: ");

    switch (ehdr->e_type) {

        case ET_EXEC: printf("Executable file\n"); break;

        case ET_DYN: printf("Shared object file\n"); break;

        case ET_REL: printf("Relocatable file\n"); break;

        default: printf("Unknown (%d)\n", ehdr->e_type);

    }

    printf("Entry point: 0x%lx\n", ehdr->e_entry);

    printf("Program headers: %d (offset: 0x%lx)\n",

           ehdr->e_phnum, ehdr->e_phoff);

    // Examine program headers (kernel maps these)

    Elf64_Phdr *phdr = (Elf64_Phdr *)((char *)map + ehdr->e_phoff);

    printf("\n=== Program Headers (Memory Layout) ===\n");

    for (int i = 0; i < ehdr->e_phnum; i++) {

        if (phdr[i].p_type == PT_LOAD) {

            printf("LOAD segment %d:\n", i);

            printf("  File offset: 0x%lx - 0x%lx\n",

                   phdr[i].p_offset, phdr[i].p_offset + phdr[i].p_filesz);

            printf("  Memory: 0x%lx - 0x%lx\n",

                   phdr[i].p_vaddr, phdr[i].p_vaddr + phdr[i].p_memsz);

            printf("  Permissions: %c%c%c\n",

                   phdr[i].p_flags & PF_R ? 'R' : '-',

                   phdr[i].p_flags & PF_W ? 'W' : '-',

                   phdr[i].p_flags & PF_X ? 'X' : '-');

        } else if (phdr[i].p_type == PT_INTERP) {

            printf("INTERP: %s\n", (char *)map + phdr[i].p_offset);

        }

    }

cleanup:

    munmap(map, st.st_size);

    close(fd);

}

int main(int argc, char *argv[]) {

    if (argc != 2) {

        fprintf(stderr, "Usage: %s <elf-file>\n", argv[0]);

        return 1;

    }

    analyze_elf_file(argv[1]);

    return 0;

}

Run on our simple program:

$ ./kernel_loader_trace ./simple_program

=== ELF File Analysis (What the Kernel Sees) ===

ELF Magic: 7f 45 4c 46

Type: Shared object file

Entry point: 0x1060

Program headers: 13 (offset: 0x40)

=== Program Headers (Memory Layout) ===

LOAD segment 0:

  File offset: 0x0 - 0x618

  Memory: 0x0 - 0x618

  Permissions: R--

LOAD segment 1:

  File offset: 0x1000 - 0x1205

  Memory: 0x1000 - 0x1205

  Permissions: R-X

LOAD segment 2:

  File offset: 0x2000 - 0x2094

  Memory: 0x2000 - 0x2094

  Permissions: R--

LOAD segment 3:

  File offset: 0x2db8 - 0x3010

  Memory: 0x3db8 - 0x3018

  Permissions: RW-

INTERP: /lib64/ld-linux-x86-64.so.2

The kernel’s job is to validate the ELF format (checking that magic number first), then map the LOAD segments into memory with the specified permissions. The INTERP segment tells the kernel “this executable needs a dynamic linker; run /lib64/ld-linux-x86-64.so.2 as the initial program instead of jumping to the entry point directly.”

This is why format validation is a kernel responsibility: if an attacker can craft a malicious ELF file that exploits a kernel parsing bug, they could gain kernel-level privileges. The kernel is paranoid about binary format parsing for good reason.

The Kernel’s Loading Process: Step by Step

Let’s create a program that demonstrates each phase of loading:

// loading_phases.c - Demonstrate different loading phases

#define _GNU_SOURCE  // Required for dl_iterate_phdr and struct dl_phdr_info

#include <stdio.h>

#include <stdlib.h>

#include <string.h>  // Required for strstr

#include <unistd.h>

#include <sys/mman.h>

#include <sys/auxv.h>

#include <elf.h>

#include <link.h>    // Must come after _GNU_SOURCE for struct dl_phdr_info

// Global variables to observe memory layout

int initialized_data = 42;

int uninitialized_data;

const char constant_data[] = "This is read-only";

// Function to print memory map

void print_memory_map(void) {

    FILE *maps = fopen("/proc/self/maps", "r");

    if (!maps) return;

    printf("\n=== Process Memory Map ===\n");

    char line[256];

    while (fgets(line, sizeof(line), maps)) {

        if (strstr(line, "loading_phases") ||    // Our executable

            strstr(line, "[stack]") ||           // Stack

            strstr(line, "[heap]") ||            // Heap

            strstr(line, "ld-") ||               // Dynamic linker

            strstr(line, "[vdso]")) {           // VDSO

            printf("%s", line);

        }

    }

    fclose(maps);

}

// Callback for dl_iterate_phdr

static int callback(struct dl_phdr_info *info, size_t size, void *data) {

    printf("Library: %s\n", info->dlpi_name[0] ? info->dlpi_name : "(main)");

    printf("  Base address: %p\n", (void *)info->dlpi_addr);

    printf("  %d segments loaded\n", info->dlpi_phnum);

    return 0;

}

int main(int argc, char *argv[], char *envp[]) {

    printf("=== Executable Loading Analysis ===\n");

    // 1. Print basic process info

    printf("\n1. Process Information:\n");

    printf("   PID: %d\n", getpid());

    printf("   Executable: %s\n", argv[0]);

    // 2. Examine auxiliary vector (passed by kernel)

    printf("\n2. Auxiliary Vector (Kernel → Program Communication):\n");

    printf("   Page size: %ld bytes\n", getauxval(AT_PAGESZ));

    printf("   Entry point: %p\n", (void *)getauxval(AT_ENTRY));

    printf("   Program headers: %p\n", (void *)getauxval(AT_PHDR));

    printf("   Number of headers: %ld\n", getauxval(AT_PHNUM));

    printf("   Interpreter base: %p\n", (void *)getauxval(AT_BASE));

    // 3. Show memory addresses

    printf("\n3. Memory Layout:\n");

    printf("   Code (main): %p\n", (void *)main);

    printf("   Initialized data: %p (value: %d)\n",

           &initialized_data, initialized_data);

    printf("   Uninitialized data: %p\n", &uninitialized_data);

    printf("   Constant data: %p\n", constant_data);

    printf("   Stack (local var): %p\n", &argc);

    printf("   Heap (malloc): %p\n", malloc(1));

    // 4. Show loaded libraries

    printf("\n4. Loaded Libraries:\n");

    dl_iterate_phdr(callback, NULL);

    // 5. Display memory map

    print_memory_map();

    return 0;

}

Compile and run:

$ gcc -o loading_phases loading_phases.c -ldl

$ ./loading_phases

Output (Ubuntu 22.04, GCC 11.4.0)

=== Executable Loading Analysis ===

1. Process Information:

   PID: 5106

   Executable: ./loading_phases

2. Auxiliary Vector (Kernel → Program Communication):

   Page size: 4096 bytes

   Entry point: 0x5555555551a0

   Program headers: 0x555555554040

   Number of headers: 13

   Interpreter base: 0x7ffffffc4000

3. Memory Layout:

   Code (main): 0x55555555547e

   Initialized data: 0x555555558010 (value: 42)

   Uninitialized data: 0x555555558018

   Constant data: 0x555555556010

   Stack (local var): 0x7ffffffc3b7c

   Heap (malloc): 0x55555555a2b0

4. Loaded Libraries:

Library: (main)

  Base address: 0x555555554000

  13 segments loaded

Library: /lib/x86_64-linux-gnu/libc.so.6

  Base address: 0x7fffff597000

  14 segments loaded

Library: /lib64/ld-linux-x86-64.so.2

  Base address: 0x7ffffffc4000

  11 segments loaded

=== Process Memory Map ===

555555554000-555555555000 r--p 00000000 00:2e 70  /workspace/loading_phases

555555555000-555555556000 r-xp 00001000 00:2e 70  /workspace/loading_phases

555555556000-555555557000 r--p 00002000 00:2e 70  /workspace/loading_phases

555555557000-555555558000 r--p 00002000 00:2e 70  /workspace/loading_phases

555555558000-555555559000 rw-p 00003000 00:2e 70  /workspace/loading_phases

7ffffffc4000-7fffffff0000 r-xp 00000000 00:3f 33821901  /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

ffff8be0f000-ffff8be10000 r-xp 00000000 00:00 0  [vdso]

ffffd0500000-ffffd0521000 rw-p 00000000 00:00 0  [stack]

Output (Fedora 43, GCC 15.2.1)

=== Executable Loading Analysis ===

1. Process Information:

   PID: 215

   Executable: ./loading_phases

2. Auxiliary Vector (Kernel → Program Communication):

   Page size: 4096 bytes

   Entry point: 0x400410

   Program headers: 0x400040

   Number of headers: 13

   Interpreter base: 0x7ffffffc7000

3. Memory Layout:

   Code (main): 0x400676

   Initialized data: 0x403054 (value: 42)

   Uninitialized data: 0x40305c

   Constant data: 0x4013a0

   Stack (local var): 0x7ffffffc6aec

   Heap (malloc): 0x405320

4. Loaded Libraries:

Library: (main)

  Base address: (nil)

  13 segments loaded

Library: /lib64/libc.so.6

  Base address: 0x7fffff5cf000

  14 segments loaded

Library: /lib64/ld-linux-x86-64.so.2

  Base address: 0x7ffffffc7000

  10 segments loaded

=== Process Memory Map ===

00400000-00401000 r-xp 00000000 00:2e 70  /workspace/loading_phases

00401000-00402000 r--p 00001000 00:2e 70  /workspace/loading_phases

00402000-00403000 r--p 00001000 00:2e 70  /workspace/loading_phases

00403000-00404000 rw-p 00002000 00:2e 70  /workspace/loading_phases

7ffffffc7000-7fffffff1000 r-xp 00000000 00:51 34030696  /usr/lib64/ld-linux-x86-64.so.2

ffff93cdd000-ffff93cde000 r-xp 00000000 00:00 0  [vdso]

ffffc8bc5000-ffffc8be6000 rw-p 00000000 00:00 0  [stack]

Important Notes

Memory addresses differ between distributions due to ASLR (Address Space Layout Randomization) and different default linker configurations.

Ubuntu uses position-independent executables (PIE) by default, resulting in randomized addresses starting around 0x55555555….

Fedora may use non-PIE executables by default, resulting in fixed addresses starting at 0x400000.

Library paths differ: /lib/x86_64-linux-gnu/ on Ubuntu/Debian vs. /lib64/ on Fedora.

The behavior and structure are identical across all distributions.

Understanding Memory Mapping

The kernel uses demand paging to efficiently load programs. Let’s demonstrate this:

// demand_paging_demo.c - Demonstrate demand paging

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <sys/mman.h>

#include <sys/time.h>

#include <sys/resource.h>

#include <string.h>

#define PAGE_SIZE 4096

#define NUM_PAGES 1000

// Function to get current RSS (Resident Set Size)

long get_rss(void) {

    struct rusage usage;

    getrusage(RUSAGE_SELF, &usage);

    return usage.ru_maxrss;  // In kilobytes on Linux

}

// Large array in BSS (not allocated until accessed)

char large_bss_array[NUM_PAGES * PAGE_SIZE];

// Large initialized array (in the executable file)

char large_data_array[1024 * 1024] = {[0] = 1, [1024*1024-1] = 2};

int main(void) {

    printf("=== Demand Paging Demonstration ===\n");

    printf("Page size: %d bytes\n", PAGE_SIZE);

    printf("Large arrays: BSS=%zu MB, DATA=%zu MB\n",

           sizeof(large_bss_array) / (1024*1024),

           sizeof(large_data_array) / (1024*1024));

    // Initial memory usage

    printf("\n1. Initial state:\n");

    printf("   RSS: %ld KB\n", get_rss());

    // Map a large region but don't touch it

    printf("\n2. After mmap (not touched):\n");

    size_t map_size = 100 * 1024 * 1024;  // 100 MB

    char *mapped = mmap(NULL, map_size, PROT_READ | PROT_WRITE,

                       MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

    if (mapped == MAP_FAILED) {

        perror("mmap");

        return 1;

    }

    printf("   Mapped %zu MB at %p\n", map_size / (1024*1024), mapped);

    printf("   RSS: %ld KB (no change!)\n", get_rss());

    // Touch one page

    printf("\n3. After touching one page:\n");

    mapped[0] = 'A';

    printf("   RSS: %ld KB (+4 KB)\n", get_rss());

    // Touch pages gradually

    printf("\n4. Touching pages gradually:\n");

    for (int i = 0; i < 10; i++) {

        // Touch a page 1MB apart

        mapped[i * 1024 * 1024] = 'B';

        printf("   Touched page %d, RSS: %ld KB\n", i, get_rss());

    }

    // Access BSS array

    printf("\n5. Accessing BSS array:\n");

    long rss_before = get_rss();

    memset(large_bss_array, 0, sizeof(large_bss_array));

    long rss_after = get_rss();

    printf("   RSS increased by: %ld KB\n", rss_after - rss_before);

    // Show memory map

    printf("\n6. Current memory mappings:\n");

    char cmd[256];

    snprintf(cmd, sizeof(cmd), "grep '\\[heap\\]\\|\\[stack\\]\\|%lx' /proc/%d/maps", (unsigned long)mapped, getpid());

    system(cmd);

    munmap(mapped, map_size);

    return 0;

}

Binary Format Detection: Beyond ELF

Linux supports multiple binary formats. Let’s see how the kernel detects them:

// binary_formats.c - Demonstrate different executable formats

#include <stdio.h>

#include <string.h>

#include <unistd.h>

#include <fcntl.h>

#include <sys/stat.h>

// Create a shell script

void create_shell_script(const char *filename) {

    FILE *f = fopen(filename, "w");

    fprintf(f, "#!/bin/bash\n");

    fprintf(f, "echo 'Hello from shell script!'\n");

    fprintf(f, "echo 'PID: '$$\n");

    fclose(f);

    chmod(filename, 0755);

}

// Create a Python script

void create_python_script(const char *filename) {

    FILE *f = fopen(filename, "w");

    fprintf(f, "#!/usr/bin/env python3\n");

    fprintf(f, "import os\n");

    fprintf(f, "print('Hello from Python!')\n");

    fprintf(f, "print(f'PID: {os.getpid()}')\n");

    fclose(f);

    chmod(filename, 0755);

}

// Examine binary format

void examine_file(const char *filename) {

    int fd = open(filename, O_RDONLY);

    if (fd < 0) return;

    unsigned char magic[4];

    read(fd, magic, sizeof(magic));

    close(fd);

    printf("\n%s:\n", filename);

    printf("  Magic bytes: %02x %02x %02x %02x\n",

           magic[0], magic[1], magic[2], magic[3]);

    if (magic[0] == 0x7f && magic[1] == 'E' &&

        magic[2] == 'L' && magic[3] == 'F') {

        printf("  Format: ELF executable\n");

    } else if (magic[0] == '#' && magic[1] == '!') {

        printf("  Format: Script (shebang)\n");

    } else {

        printf("  Format: Unknown\n");

    }

}

int main(void) {

    printf("=== Binary Format Detection ===\n");

    // Create test files

    create_shell_script("test_script.sh");

    create_python_script("test_script.py");

    // Examine formats

    examine_file("/bin/ls");

    examine_file("test_script.sh");

    examine_file("test_script.py");

    examine_file("./binary_formats");  // This program

    // Execute different formats

    printf("\n=== Executing Different Formats ===\n");

    printf("\n1. Shell script:\n");

    system("./test_script.sh");

    printf("\n2. Python script:\n");

    system("./test_script.py");

    // Trace how kernel handles scripts

    printf("\n3. Tracing script execution:\n");

    system("strace -e execve ./test_script.sh 2>&1 | grep execve");

    // Clean up

    unlink("test_script.sh");

    unlink("test_script.py");

    return 0;

}

The VDSO: Kernel Services in Userspace

The Virtual Dynamic Shared Object (VDSO) is a fascinating optimization:

// vdso_demo.c - Explore the VDSO

#include <stdio.h>

#include <time.h>

#include <sys/time.h>

#include <unistd.h>

#include <sys/auxv.h>

#include <string.h>

// Measure function call overhead

double time_function(void (*func)(void), const char *name, int iterations) {

    struct timespec start, end;

    clock_gettime(CLOCK_MONOTONIC, &start);

    for (int i = 0; i < iterations; i++) {

        func();

    }

    clock_gettime(CLOCK_MONOTONIC, &end);

    double elapsed = (end.tv_sec - start.tv_sec) +

                    (end.tv_nsec - start.tv_nsec) / 1e9;

    double per_call = elapsed / iterations * 1e9;  // nanoseconds

    printf("%s: %.1f ns per call\n", name, per_call);

    return per_call;

}

// Functions to test

void test_getpid(void) {

    getpid();  // Requires system call

}

void test_gettimeofday(void) {

    struct timeval tv;

    gettimeofday(&tv, NULL);  // Uses VDSO

}

void test_clock_gettime(void) {

    struct timespec ts;

    clock_gettime(CLOCK_MONOTONIC, &ts);  // Uses VDSO

}

void test_time(void) {

    time(NULL);  // Uses VDSO

}

int main(void) {

    printf("=== VDSO Demonstration ===\n");

    // Find VDSO location

    unsigned long vdso = getauxval(AT_SYSINFO_EHDR);

    printf("VDSO mapped at: %p\n", (void *)vdso);

    // Show VDSO in memory map

    printf("\nVDSO in /proc/self/maps:\n");

    system("grep vdso /proc/self/maps");

    // List VDSO exports

    printf("\nVDSO exported symbols:\n");

    char cmd[256];

    snprintf(cmd, sizeof(cmd),

             "objdump --dynamic-syms /proc/%d/mem 2>/dev/null | grep -A20 'file format' | grep -E 'clock_gettime|gettimeofday|time' || echo 'Symbol listing requires root'",

             getpid());

    system(cmd);

    // Performance comparison

    printf("\n=== Performance Comparison ===\n");

    printf("Testing with 1,000,000 iterations:\n\n");

    double getpid_time = time_function(test_getpid, "getpid (syscall)", 1000000);

    double gettimeofday_time = time_function(test_gettimeofday, "gettimeofday (VDSO)", 1000000);

    double clock_gettime_time = time_function(test_clock_gettime, "clock_gettime (VDSO)", 1000000);

    double time_time = time_function(test_time, "time (VDSO)", 1000000);

    printf("\nSpeedup from VDSO:\n");

    printf("gettimeofday: %.1fx faster than syscall\n", getpid_time / gettimeofday_time);

    printf("clock_gettime: %.1fx faster than syscall\n", getpid_time / clock_gettime_time);

    printf("time: %.1fx faster than syscall\n", getpid_time / time_time);

    return 0;

}

Debugging Kernel Loading Issues

Let’s create tools to debug common loading problems:

// loader_debugger.c - Debug executable loading issues

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <fcntl.h>

#include <elf.h>

#include <errno.h>

#include <string.h>

#include <sys/stat.h>

#include <sys/mman.h>

void check_executable(const char *path) {

    printf("\n=== Checking: %s ===\n", path);

    // 1. Check file existence and permissions

    struct stat st;

    if (stat(path, &st) < 0) {

        printf("File not found: %s\n", strerror(errno));

        return;

    }

    printf("✓ File exists\n");

    printf("  Size: %ld bytes\n", st.st_size);

    printf("  Permissions: %o\n", st.st_mode & 0777);

    if (!(st.st_mode & S_IXUSR)) {

        printf("Not executable by owner\n");

    }

    // 2. Check if it's a regular file

    if (!S_ISREG(st.st_mode)) {

        printf("Not a regular file\n");

        return;

    }

    // 3. Open and check format

    int fd = open(path, O_RDONLY);

    if (fd < 0) {

        printf("Cannot open file: %s\n", strerror(errno));

        return;

    }

    // 4. Read header

    unsigned char header[128];

    ssize_t n = read(fd, header, sizeof(header));

    if (n < 4) {

        printf("File too small\n");

        close(fd);

        return;

    }

    // 5. Identify format

    if (memcmp(header, ELFMAG, SELFMAG) == 0) {

        printf("ELF executable detected\n");

        Elf64_Ehdr *ehdr = (Elf64_Ehdr *)header;

        printf("  Class: %s\n",

               ehdr->e_ident[EI_CLASS] == ELFCLASS64 ? "64-bit" : "32-bit");

        printf("  Type: ");

        switch (ehdr->e_type) {

            case ET_EXEC: printf("Executable\n"); break;

            case ET_DYN: printf("Shared object\n"); break;

            case ET_REL: printf("Relocatable\n"); break;

            default: printf("Unknown (%d)\n", ehdr->e_type);

        }

        // Check for interpreter

        lseek(fd, ehdr->e_phoff, SEEK_SET);

        for (int i = 0; i < ehdr->e_phnum; i++) {

            Elf64_Phdr phdr;

            read(fd, &phdr, sizeof(phdr));

            if (phdr.p_type == PT_INTERP) {

                char interp[256];

                lseek(fd, phdr.p_offset, SEEK_SET);

                read(fd, interp, sizeof(interp)-1);

                interp[phdr.p_filesz] = '\0';

                printf("  Interpreter: %s\n", interp);

                // Check if interpreter exists

                if (access(interp, X_OK) != 0) {

                    printf("Interpreter not found or not executable!\n");

                }

                break;

            }

        }

    } else if (header[0] == '#' && header[1] == '!') {

        printf("✓ Script detected\n");

        // Extract interpreter

        char *newline = memchr(header, '\n', n);

        if (newline) *newline = '\0';

        printf("  Shebang: %s\n", header);

        // Parse interpreter

        char interp[256];

        sscanf((char *)header + 2, "%s", interp);

        if (access(interp, X_OK) != 0) {

            printf("Script interpreter not found: %s\n", interp);

        }

    } else {

        printf("Unknown format\n");

        printf("  Magic: %02x %02x %02x %02x\n",

               header[0], header[1], header[2], header[3]);

    }

    close(fd);

    // 6. Try to execute

    printf("\nAttempting execution:\n");

    pid_t pid = fork();

    if (pid == 0) {

        execl(path, path, "test", NULL);

        printf("Exec failed: %s\n", strerror(errno));

        _exit(1);

    } else {

        int status;

        waitpid(pid, &status, 0);

        if (WIFEXITED(status) && WEXITSTATUS(status) == 0) {

            printf("Execution successful\n");

        } else {

            printf("Execution failed (status: %d)\n", WEXITSTATUS(status));

        }

    }

}

int main(int argc, char *argv[]) {

    if (argc < 2) {

        fprintf(stderr, "Usage: %s <executable> [executable...]\n", argv[0]);

        return 1;

    }

    printf("=== Executable Loading Debugger ===\n");

    for (int i = 1; i < argc; i++) {

        check_executable(argv[i]);

    }

    return 0;

}

Performance Impact of Different Loading Strategies

Let’s measure the performance implications:

// loading_performance.c - Measure loading performance

#include <stdio.h>

#include <stdlib.h>

#include <time.h>

#include <unistd.h>

#include <sys/wait.h>

#include <sys/time.h>

double measure_exec_time(const char *program, char *const argv[], char *const envp[]) {

    struct timeval start, end;

    gettimeofday(&start, NULL);

    pid_t pid = fork();

    if (pid == 0) {

        execve(program, argv, envp);

        _exit(1);

    }

    int status;

    waitpid(pid, &status, 0);

    gettimeofday(&end, NULL);

    return (end.tv_sec - start.tv_sec) + (end.tv_usec - start.tv_usec) / 1e6;

}

int main(void) {

    printf("=== Loading Performance Analysis ===\n");

    // Test 1: Minimal program

    printf("\n1. Minimal static program:\n");

    system("echo 'int main(){}' | gcc -static -x c -o minimal_static -");

    system("echo 'int main(){}' | gcc -x c -o minimal_dynamic -");

    char *argv[] = {NULL};

    char *envp[] = {NULL};

    double static_time = 0, dynamic_time = 0;

    int iterations = 100;

    for (int i = 0; i < iterations; i++) {

        static_time += measure_exec_time("./minimal_static", argv, envp);

        dynamic_time += measure_exec_time("./minimal_dynamic", argv, envp);

    }

    printf("Average over %d runs:\n", iterations);

    printf("  Static: %.3f ms\n", (static_time / iterations) * 1000);

    printf("  Dynamic: %.3f ms\n", (dynamic_time / iterations) * 1000);

    printf("  Dynamic overhead: %.1f%%\n",

           ((dynamic_time - static_time) / static_time) * 100);

    // Test 2: With environment

    printf("\n2. With full environment:\n");

    extern char **environ;

    double env_time = 0;

    for (int i = 0; i < iterations; i++) {

        env_time += measure_exec_time("./minimal_dynamic", argv, environ);

    }

    printf("  With environment: %.3f ms\n", (env_time / iterations) * 1000);

    printf("  Environment overhead: %.1f%%\n",

           ((env_time - dynamic_time) / dynamic_time) * 100);

    // Clean up

    unlink("minimal_static");

    unlink("minimal_dynamic");

    return 0;

}

Summary

Our deep dive into Linux executable loading reveals a sophisticated system that balances performance, security, and flexibility. From the initial execve system call to the final jump to user code, the kernel orchestrates a complex dance involving

1.

Binary Format Detection: Supporting multiple executable types through a pluggable system

2.

Security Validation: Checking permissions and preventing privilege escalation

3.

Memory Mapping: Efficiently loading code and data with demand paging

4.

Dynamic Linker Handoff: Seamlessly transitioning to userspace for library resolution

5.

VDSO Optimization: Providing kernel services without syscall overhead

Key Insights from Our Exploration

The kernel only loads what’s immediately necessary, using demand paging for efficiency.

The auxiliary vector provides a clean communication channel from kernel to userspace.

Binary format handlers allow Linux to execute various file types transparently.

The VDSO can provide 30–50× speedup for frequently called system functions.

Loading performance is dominated by dynamic linking for typical programs.

This knowledge empowers us to

Debug mysterious “Exec format error” problems.

Optimize program startup time.

Understand security implications of executable loading.

Build custom binary formats or loaders.

Diagnose performance issues in program initialization.

The elegance of Linux’s executable loading showcases decades of evolution in operating system design, providing a foundation that scales from embedded systems to supercomputers.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_13

13. Weak Symbols: A Linker’s Flexibility

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

Imagine you’re building a library that needs to work across different environments—some with advanced features, others more basic. You want to provide optimized implementations, when possible, but fall back to simpler versions when necessary. Or perhaps you’re creating a testing framework where users can override certain functions with mocks. This is where weak symbols shine, providing an elegant solution to these challenges through the linking process itself.

The Problem: One Size Doesn’t Fit All

Let’s start with a common scenario that demonstrates why weak symbols exist:

// logging_library.c - A library with optional features

#include <stdio.h>

#include <time.h>

// What if the user wants to provide their own timestamp function?

void get_timestamp(char *buffer, size_t size) {

    time_t now = time(NULL);

    strftime(buffer, size, "%Y-%m-%d %H:%M:%S", localtime(&now));

}

void log_message(const char *level, const char *message) {

    char timestamp[32];

    get_timestamp(timestamp, sizeof(timestamp));

    printf("[%s] %s: %s\n", timestamp, level, message);

}

// application.c - Using the library

#include <stdio.h>

extern void log_message(const char *level, const char *message);

int main(void) {

    log_message("INFO", "Application started");

    log_message("DEBUG", "Processing data");

    return 0;

}

Compile and run:

$ gcc -c logging_library.c

$ gcc -c application.c

$ gcc -o app application.o logging_library.o

$ ./app

Output (Tested on Ubuntu 22.04 and Fedora 43)

[2025-12-06 20:15:25] INFO: Application started

[2025-12-06 20:15:25] DEBUG: Processing data

But what if we want a different timestamp format? We’d get a linker error:

// custom_timestamp.c

void get_timestamp(char *buffer, size_t size) {

    // High-precision timestamp

    snprintf(buffer, size, "%.6f", get_precise_time());

}

$ gcc -c custom_timestamp.c

$ gcc -o app application.o logging_library.o custom_timestamp.o

/usr/bin/ld: custom_timestamp.o: multiple definition of `get_timestamp'

logging_library.o: first defined here

The linker can’t decide which version of get_timestamp to use. This is where weak symbols provide an elegant solution! Weak symbols allow one version to provide a default implementation while allowing optional overrides. It’s a mechanism for saying “I have an implementation of this, but if someone else provides one, use theirs instead.”

Understanding Weak Symbols

Weak symbols are symbols that can be overridden by strong (normal) symbols during linking. Let’s fix our library:

// logging_library_v2.c - Using weak symbols

#include <stdio.h>

#include <time.h>

// Weak default implementation

__attribute__((weak)) void get_timestamp(char *buffer, size_t size) {

    time_t now = time(NULL);

    strftime(buffer, size, "%Y-%m-%d %H:%M:%S", localtime(&now));

}

// Weak hook for custom formatting

__attribute__((weak)) void format_log_entry(char *output, size_t size,

                                           const char *timestamp,

                                           const char *level,

                                           const char *message) {

    snprintf(output, size, "[%s] %s: %s", timestamp, level, message);

}

void log_message(const char *level, const char *message) {

    char timestamp[64];

    char formatted[256];

    get_timestamp(timestamp, sizeof(timestamp));

    format_log_entry(formatted, sizeof(formatted), timestamp, level, message);

    printf("%s\n", formatted);

}

Now, users can optionally override these functions:

// custom_logger.c - Override weak symbols

#include <stdio.h>

#include <sys/time.h>

// Strong symbol overrides weak one

void get_timestamp(char *buffer, size_t size) {

    struct timeval tv;

    gettimeofday(&tv, NULL);

    snprintf(buffer, size, "%ld.%06ld", tv.tv_sec, tv.tv_usec);

}

// Custom format with colors

void format_log_entry(char *output, size_t size,

                     const char *timestamp,

                     const char *level,

                     const char *message) {

    const char *color = "";

    if (strcmp(level, "ERROR") == 0) color = "\033[31m";      // Red

    else if (strcmp(level, "WARN") == 0) color = "\033[33m";  // Yellow

    else if (strcmp(level, "INFO") == 0) color = "\033[32m";  // Green

    snprintf(output, size, "%s[%s] %-5s: %s\033[0m",

             color, timestamp, level, message);

}

Testing the Weak Symbol Override

# Default implementation (weak symbols from library)

$ gcc -o app_default application.c logging_library_v2.c

$ ./app_default

Output (Ubuntu 22.04/Fedora 43)

[2025-12-06 20:15:25] INFO: Application started

[2025-12-06 20:15:25] DEBUG: Processing data

[2025-12-06 20:15:25] ERROR: Something went wrong

# Custom implementation (strong symbols override weak ones)

$ gcc -o app_custom application.c logging_library_v2.c custom_logger.c

$ ./app_custom

Output (Ubuntu 22.04/Fedora 43)

[32m[1765052126.821117] INFO : Application started[0m

[1765052126.822350] DEBUG: Processing data[0m

[31m[1765052126.822351] ERROR: Something went wrong[0m

Note

The [32m, [31m, and [0m are ANSI color codes (green, red, reset). In a terminal, INFO appears green and ERROR appears red. The timestamps are now high-precision Unix timestamps instead of formatted dates.

Why is this better than alternatives? Without weak symbols, you’d need

1.

Function Pointers: Users would have to explicitly call initialization functions to register handlers. Weak symbols work automatically.

2.

Configuration at Link Time: You’d have to rebuild the library differently for each configuration.

3.

Runtime Callbacks: Adds complexity to your library’s initialization.

Weak symbols provide a clean, linker-level solution that requires no code changes on the user’s part—just provide a strong definition with the same name, and the linker handles the rest.

Examining Weak Symbols

Let’s create tools to understand how weak symbols work:

// symbol_explorer.c - Examine symbol types

#include <stdio.h>

#include <stdlib.h>

// Different symbol types

int strong_global = 42;

__attribute__((weak)) int weak_global = 10;

static int static_global = 100;

void strong_function(void) {

    printf("Strong function\n");

}

__attribute__((weak)) void weak_function(void) {

    printf("Weak function (default)\n");

}

static void static_function(void) {

    printf("Static function\n");

}

// Weak with no definition (like a declaration)

__attribute__((weak)) void optional_function(void);

int main(void) {

    printf("=== Symbol Type Demonstration ===\n");

    printf("Strong global: %d\n", strong_global);

    printf("Weak global: %d\n", weak_global);

    printf("Static global: %d\n", static_global);

    strong_function();

    weak_function();

    static_function();

    // Check if optional function exists

    if (optional_function) {

        printf("Optional function is available\n");

        optional_function();

    } else {

        printf("Optional function is not available\n");

    }

    return 0;

}

Run the program:

$ gcc -o symbol_explorer symbol_explorer.c

$ ./symbol_explorer

Output (Ubuntu 22.04/Fedora 43)

=== Symbol Type Demonstration ===

Strong global: 42

Weak global: 10

Static global: 100

Strong function

Weak function (default)

Static function

Optional function is not available

Let’s examine the symbols:

$ gcc -c symbol_explorer.c

$ nm symbol_explorer.o

Output (Ubuntu 22.04/Fedora 43)

000000000000004e T main

                 w optional_function

0000000000000000 T strong_function

0000000000000000 D strong_global

000000000000001a W weak_function

0000000000000008 d static_global

0000000000000034 t static_function

Note

The nm output format may vary slightly between distributions, but the symbol types are consistent:

T = Text (strong function)

D = Data (strong initialized)

W = Weak function

V = Weak data (shown as w in some versions)

t = Local text (static function)

d = Local data (static variable)

Symbol Types

T = Text (strong function)

D = Data (strong initialized)

W = Weak function

V = Weak data

t = Local text (static function)

d = Local data (static variable)

Practical Applications of Weak Symbols

Weak symbols shine in real-world scenarios where flexibility trumps rigid design. Let’s explore three practical patterns that leverage weak symbols to solve common engineering problems.

1. Hardware Abstraction Layers

Hardware abstraction layers (HALs) must work across different platforms without forcing users to modify library code. Weak symbols provide an elegant solution: the library provides generic implementations that work everywhere, while platform-specific code can override them with optimized versions. This pattern is ubiquitous in embedded systems and cross-platform libraries.

// hal.h - Hardware Abstraction Layer

#ifndef HAL_H

#define HAL_H

#include <stdint.h>

// Weak symbols for platform-specific implementations

__attribute__((weak)) void hal_init(void);

__attribute__((weak)) void hal_delay_ms(uint32_t ms);

__attribute__((weak)) void hal_led_on(void);

__attribute__((weak)) void hal_led_off(void);

__attribute__((weak)) uint32_t hal_get_tick(void);

#endif

// hal_generic.c - Generic implementations

#include "hal.h"

#include <unistd.h>

#include <time.h>

#include <stdio.h>

__attribute__((weak)) void hal_init(void) {

    printf("Generic HAL initialized\n");

}

__attribute__((weak)) void hal_delay_ms(uint32_t ms) {

    usleep(ms * 1000);

}

__attribute__((weak)) void hal_led_on(void) {

    printf("LED: ON\n");

}

__attribute__((weak)) void hal_led_off(void) {

    printf("LED: OFF\n");

}

__attribute__((weak)) uint32_t hal_get_tick(void) {

    struct timespec ts;

    clock_gettime(CLOCK_MONOTONIC, &ts);

    return ts.tv_sec * 1000 + ts.tv_nsec / 1000000;

}

// hal_raspberry_pi.c - Platform-specific overrides

#include "hal.h"

#include <fcntl.h>

#include <stdio.h>

static int gpio_fd = -1;

void hal_init(void) {

    printf("Raspberry Pi HAL initialized\n");

    // Open GPIO device

    gpio_fd = open("/sys/class/gpio/gpio17/value", O_WRONLY);

}

void hal_led_on(void) {

    if (gpio_fd >= 0) {

        write(gpio_fd, "1", 1);

    }

    printf("GPIO17: HIGH\n");

}

void hal_led_off(void) {

    if (gpio_fd >= 0) {

        write(gpio_fd, "0", 1);

    }

    printf("GPIO17: LOW\n");

}

// application_hal.c - Using the HAL

#include "hal.h"

#include <stdio.h>

int main(void) {

    hal_init();

    printf("Blinking LED...\n");

    for (int i = 0; i < 5; i++) {

        hal_led_on();

        hal_delay_ms(500);

        hal_led_off();

        hal_delay_ms(500);

    }

    return 0;

}

Build for different platforms:

# Generic version

$ gcc -o app_generic application_hal.c hal_generic.c

$ ./app_generic

Generic HAL initialized

Blinking LED...

LED: ON

LED: OFF

...

# Raspberry Pi version

$ gcc -o app_rpi application_hal.c hal_generic.c hal_raspberry_pi.c

$ ./app_rpi

Raspberry Pi HAL initialized

Blinking LED...

GPIO17: HIGH

GPIO17: LOW

...

Notice how the same application code works on both platforms. When you link with only hal_generic.c, the weak implementations are used. When you add hal_raspberry_pi.c, its strong symbols override the weak ones for GPIO-specific functions, while the generic implementations remain for functions not overridden. This pattern scales to dozens of platforms without modifying the core application or library code.

2. Memory Allocator with Debug Hooks

Memory debugging is essential during development but adds overhead in production. Weak symbols let you build a single binary that supports both modes. The library provides empty weak hooks that do nothing in production, but developers can link in a debug implementation that tracks allocations without changing any application code.

// malloc_debug.h

#ifndef MALLOC_DEBUG_H

#define MALLOC_DEBUG_H

#include <stddef.h>

// Weak symbols for memory debugging

__attribute__((weak)) void malloc_debug_init(void);

__attribute__((weak)) void malloc_debug_alloc(void *ptr, size_t size);

__attribute__((weak)) void malloc_debug_free(void *ptr);

__attribute__((weak)) void malloc_debug_report(void);

#endif

// malloc_wrapper.c - Allocation wrapper

#include "malloc_debug.h"

#include <stdlib.h>

#include <stdio.h>

// Weak empty implementations

__attribute__((weak)) void malloc_debug_init(void) {}

__attribute__((weak)) void malloc_debug_alloc(void *ptr, size_t size) {}

__attribute__((weak)) void malloc_debug_free(void *ptr) {}

__attribute__((weak)) void malloc_debug_report(void) {}

void *debug_malloc(size_t size) {

    void *ptr = malloc(size);

    malloc_debug_alloc(ptr, size);

    return ptr;

}

void debug_free(void *ptr) {

    malloc_debug_free(ptr);

    free(ptr);

}

// malloc_debug_impl.c - Debug implementation

// IMPORTANT: Don't include the header here! Including a header with weak declarations

// would make these definitions weak too. We want STRONG symbols to override the weak ones.

#include <stddef.h>

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

typedef struct allocation {

    void *ptr;

    size_t size;

    struct allocation *next;

} allocation_t;

static allocation_t *allocations = NULL;

static size_t total_allocated = 0;

static size_t total_freed = 0;

static size_t peak_usage = 0;

static size_t current_usage = 0;

void malloc_debug_init(void) {

    printf("=== Memory Debugging Enabled ===\n");

    allocations = NULL;

    total_allocated = 0;

    total_freed = 0;

    peak_usage = 0;

    current_usage = 0;

}

void malloc_debug_alloc(void *ptr, size_t size) {

    if (!ptr) return;

    allocation_t *alloc = malloc(sizeof(allocation_t));

    alloc->ptr = ptr;

    alloc->size = size;

    alloc->next = allocations;

    allocations = alloc;

    total_allocated += size;

    current_usage += size;

    if (current_usage > peak_usage) {

        peak_usage = current_usage;

    }

    printf("ALLOC: %p [%zu bytes] (current: %zu)\n", ptr, size, current_usage);

}

void malloc_debug_free(void *ptr) {

    if (!ptr) return;

    allocation_t **current = &allocations;

    while (*current) {

        if ((*current)->ptr == ptr) {

            allocation_t *to_free = *current;

            size_t size = to_free->size;

            *current = to_free->next;

            free(to_free);

            total_freed += size;

            current_usage -= size;

            printf("FREE: %p [%zu bytes] (current: %zu)\n", ptr, size, current_usage);

            return;

        }

        current = &(*current)->next;

    }

    printf("FREE: %p [UNKNOWN - not tracked!]\n", ptr);

}

void malloc_debug_report(void) {

    printf("\n=== Memory Report ===\n");

    printf("Total allocated: %zu bytes\n", total_allocated);

    printf("Total freed: %zu bytes\n", total_freed);

    printf("Peak usage: %zu bytes\n", peak_usage);

    printf("Current usage: %zu bytes\n", current_usage);

    printf("Leaked: %zu bytes\n", current_usage);

    if (allocations) {

        printf("\nLeaked allocations:\n");

        allocation_t *current = allocations;

        while (current) {

            printf("  %p: %zu bytes\n", current->ptr, current->size);

            current = current->next;

        }

    }

}

// test_program.c

// IMPORTANT: Undefine malloc/free BEFORE including headers to ensure our macros work

// This prevents conflicts with any definitions in system headers

#undef malloc

#undef free

#include "malloc_debug.h"

#include <stdio.h>

// Use macros to override standard functions

// These macros redirect all malloc/free calls to our debug versions

#define malloc(size) debug_malloc(size)

#define free(ptr) debug_free(ptr)

extern void *debug_malloc(size_t size);

extern void debug_free(void *ptr);

int main(void) {

    malloc_debug_init();

    printf("\n=== Running Program ===\n");

    void *p1 = malloc(100);

    void *p2 = malloc(200);

    void *p3 = malloc(300);

    free(p2);

    void *p4 = malloc(150);

    // Intentional leak of p3

    free(p1);

    free(p4);

    malloc_debug_report();

    return 0;

}

Compile with or without debugging:

# Production build (no debugging)

$ gcc -o app test_program.c malloc_wrapper.c

$ ./app

=== Running Program ===

The production build runs silently—the weak empty implementations do nothing, so you only see the program’s own output.

# Debug build

$ gcc -o app_debug test_program.c malloc_wrapper.c malloc_debug_impl.c

$ ./app_debug

Output on Ubuntu 22.04 (GCC 11.4.0)

=== Memory Debugging Enabled ===

=== Running Program ===

ALLOC: 0x55555555a2b0 [100 bytes] (current: 100)

ALLOC: 0x55555555a340 [200 bytes] (current: 300)

ALLOC: 0x55555555a430 [300 bytes] (current: 600)

FREE: 0x55555555a340 [200 bytes] (current: 400)

ALLOC: 0x55555555a590 [150 bytes] (current: 550)

FREE: 0x55555555a2b0 [100 bytes] (current: 450)

FREE: 0x55555555a590 [150 bytes] (current: 300)

=== Memory Report ===

Total allocated: 750 bytes

Total freed: 450 bytes

Peak usage: 600 bytes

Current usage: 300 bytes

Leaked: 300 bytes

Leaked allocations:

  0x55555555a430: 300 bytes

Output on Fedora 43 (GCC 15.2.1)

=== Memory Debugging Enabled ===

=== Running Program ===

ALLOC: 0x405320 [100 bytes] (current: 100)

ALLOC: 0x4053b0 [200 bytes] (current: 300)

ALLOC: 0x4054a0 [300 bytes] (current: 600)

FREE: 0x4053b0 [200 bytes] (current: 400)

ALLOC: 0x405600 [150 bytes] (current: 550)

FREE: 0x405320 [100 bytes] (current: 450)

FREE: 0x405600 [150 bytes] (current: 300)

=== Memory Report ===

Total allocated: 750 bytes

Total freed: 450 bytes

Peak usage: 600 bytes

Current usage: 300 bytes

Leaked: 300 bytes

Leaked allocations:

  0x4054a0: 300 bytes

Note the different memory addresses between Ubuntu and Fedora—this is normal and reflects different memory layout strategies in different glibc versions. The behavior is identical across distributions.

The same binary can be built with or without debugging by simply including or excluding the debug implementation file at link time. No preprocessor conditionals, no runtime flags—just clean separation through weak symbols.

3. Architecture-Specific Optimizations

Modern CPUs provide specialized instructions for common operations. Weak symbols let you ship a single library that automatically uses the fastest implementation available on each architecture. The generic C implementation works everywhere, but x86 processors get assembly-optimized versions that leverage hardware instructions.

// math_ops.h

#ifndef MATH_OPS_H

#define MATH_OPS_H

#include <stdint.h>

// Weak symbols for optimized implementations

__attribute__((weak)) uint32_t count_bits(uint32_t x);

__attribute__((weak)) int find_first_set(uint32_t x);

__attribute__((weak)) uint32_t rotate_left(uint32_t x, int n);

#endif

// math_ops_generic.c - Portable implementations

#include "math_ops.h"

__attribute__((weak)) uint32_t count_bits(uint32_t x) {

    uint32_t count = 0;

    while (x) {

        count += x & 1;

        x >>= 1;

    }

    return count;

}

__attribute__((weak)) int find_first_set(uint32_t x) {

    if (x == 0) return 0;

    int pos = 1;

    while ((x & 1) == 0) {

        x >>= 1;

        pos++;

    }

    return pos;

}

__attribute__((weak)) uint32_t rotate_left(uint32_t x, int n) {

    n &= 31;  // Ensure n is in range 0-31

    return (x << n) | (x >> (32 - n));

}

// math_ops_x86.c - x86-optimized versions

#include "math_ops.h"

uint32_t count_bits(uint32_t x) {

    // Use POPCNT instruction if available

    uint32_t result;

    __asm__("popcnt %1, %0" : "=r" (result) : "r" (x));

    return result;

}

int find_first_set(uint32_t x) {

    if (x == 0) return 0;

    uint32_t result;

    __asm__("bsf %1, %0" : "=r" (result) : "r" (x));

    return result + 1;

}

uint32_t rotate_left(uint32_t x, int n) {

    uint32_t result;

    __asm__("roll %%cl, %0" : "=r" (result) : "0" (x), "c" (n));

    return result;

}

// benchmark.c - Compare implementations

#include "math_ops.h"

#include <stdio.h>

#include <time.h>

#include <stdlib.h>

double benchmark_function(uint32_t (*func)(uint32_t), const char *name) {

    const int iterations = 10000000;

    uint32_t result = 0;

    clock_t start = clock();

    for (int i = 0; i < iterations; i++) {

        result += func(rand());

    }

    clock_t end = clock();

    double time = ((double)(end - start)) / CLOCKS_PER_SEC;

    printf("%s: %.3f seconds (%u)\n", name, time, result);

    return time;

}

int main(void) {

    printf("=== Math Operations Benchmark ===\n");

    srand(42);  // Reproducible results

    benchmark_function(count_bits, "count_bits");

    // Check which implementation we're using

    #ifdef __x86_64__

    printf("Using x86-64 optimized version\n");

    #else

    printf("Using generic version\n");

    #endif

    return 0;

}

Build and benchmark:

# Generic version

$ gcc -o bench_generic benchmark.c math_ops_generic.c

$ ./bench_generic

Output (Ubuntu 22.04, GCC 11.4.0)

=== Math Operations Benchmark ===

count_bits: 0.234 seconds (12345678)

Using generic version

Output (Fedora 43, GCC 15.2.1)

=== Math Operations Benchmark ===

count_bits: 0.198 seconds (12345678)

Using generic version

# Optimized x86 version (requires x86-64 architecture)

$ gcc -o bench_x86 benchmark.c math_ops_generic.c math_ops_x86.c -mpopcnt

$ ./bench_x86

Output (Ubuntu 22.04/Fedora 43, When Compiled with -mpopcnt)

=== Math Operations Benchmark ===

count_bits: 0.045 seconds (12345678)

Using x86-64 optimized version

Note

The optimized version is significantly faster (5–6×) when the POPCNT instruction is available. Performance numbers will vary based on CPU architecture and compiler optimizations.

The x86 version is over five times faster because the POPCNT instruction counts bits in a single CPU cycle, while the generic C loop requires multiple iterations. By using weak symbols, the library automatically selects the best implementation at link time based on which object files you include. Users don’t need to know about these optimizations—they just get faster code on supported platforms.

Symbol Resolution Rules

Understanding how the linker chooses between multiple symbol definitions is crucial for using weak symbols effectively. The rules are simple but have profound implications for library design.

// resolution_test.c - Symbol resolution demonstration

#include <stdio.h>

// Case 1: Weak vs Strong

__attribute__((weak)) int value1 = 10;

int value1 = 42;  // Strong wins

// Case 2: Multiple weak symbols

__attribute__((weak)) int value2 = 20;

// Case 3: Weak function with strong override

__attribute__((weak)) void function1(void) {

    printf("Weak function1\n");

}

// Case 4: Weak undefined symbol

__attribute__((weak)) extern void undefined_func(void);

// test_resolution.c

#include <stdio.h>

// Override weak symbols

int value2 = 30;  // Strong overrides weak

void function1(void) {

    printf("Strong function1\n");

}

// Provide definition for undefined weak

void undefined_func(void) {

    printf("Now defined!\n");

}

int main(void) {

    extern int value1, value2;

    printf("=== Symbol Resolution Test ===\n");

    printf("value1: %d (strong wins)\n", value1);

    printf("value2: %d (strong overrides weak)\n", value2);

    function1();

    if (undefined_func) {

        undefined_func();

    }

    return 0;

}

Compile and test the resolution:

$ gcc -c resolution_test.c test_resolution.c

$ gcc -o resolution resolution_test.o test_resolution.o

$ ./resolution

Output (Ubuntu 22.04/Fedora 43)

=== Symbol Resolution Test ===

value1: 42 (strong wins)

value2: 30 (strong overrides weak)

Strong function1

Now defined!

The strong symbols from test_resolution.c successfully override the weak ones from resolution_test.c, demonstrating the linker’s symbol resolution rules.

The linker applies these rules consistently: strong always beats weak, and among multiple weak definitions, the linker picks one arbitrarily (usually the first encountered). This predictable behavior enables the patterns we’ve explored—default implementations that can be selectively overridden.

Common Patterns and Best Practices

Weak symbols enable several recurring patterns in professional software development. These patterns solve real problems elegantly without introducing complexity.

1. Library Initialization

Libraries often need initialization and cleanup hooks, but requiring users to call explicit functions is error-prone. Weak symbols combined with constructor attributes provide automatic initialization with optional user customization.

// library_init.h

#ifndef LIBRARY_INIT_H

#define LIBRARY_INIT_H

// Weak symbols for library lifecycle

__attribute__((weak)) void lib_pre_init(void);

__attribute__((weak)) void lib_post_init(void);

__attribute__((weak)) void lib_cleanup(void);

// Main initialization function

void lib_init(void);

#endif

// library_init.c

#include "library_init.h"

#include <stdio.h>

// Weak empty defaults

__attribute__((weak)) void lib_pre_init(void) {

    // User can override for early setup

}

__attribute__((weak)) void lib_post_init(void) {

    // User can override for additional setup

}

__attribute__((weak)) void lib_cleanup(void) {

    // User can override for cleanup

}

// Constructor/destructor attributes

__attribute__((constructor))

static void lib_constructor(void) {

    lib_init();

}

__attribute__((destructor))

static void lib_destructor(void) {

    lib_cleanup();

}

void lib_init(void) {

    lib_pre_init();

    printf("Library core initialization\n");

    // Core initialization code here

    lib_post_init();

}

Users can now customize initialization without modifying library code:

// user_customization.c

void lib_pre_init(void) {

    printf("User: Setting up custom environment\n");

}

void lib_post_init(void) {

    printf("User: Registering custom handlers\n");

}

When linked together, the library automatically calls the user’s hooks at the right moments. The constructor attribute ensures initialization happens before main(), and weak symbols make the hooks optional—if users don’t provide them, the empty defaults are used.

2. Error Handling Hooks

Error handling strategies vary widely across applications. Some need detailed logging, others prefer silent failures, and testing frameworks want to intercept errors. Weak symbols let the library provide sensible defaults while allowing complete customization.

// error_handler.h

#ifndef ERROR_HANDLER_H

#define ERROR_HANDLER_H

// Weak error handlers

__attribute__((weak)) void handle_fatal_error(const char *msg);

__attribute__((weak)) void handle_assertion(const char *expr, const char *file, int line);

__attribute__((weak)) void handle_warning(const char *msg);

// Macros for easy use

#define FATAL_ERROR(msg) handle_fatal_error(msg)

#define ASSERT(expr) do { \

    if (!(expr)) handle_assertion(#expr, __FILE__, __LINE__); \

} while(0)

#define WARNING(msg) handle_warning(msg)

#endif

// error_handler_default.c

#include "error_handler.h"

#include <stdio.h>

#include <stdlib.h>

__attribute__((weak)) void handle_fatal_error(const char *msg) {

    fprintf(stderr, "FATAL: %s\n", msg);

    abort();

}

__attribute__((weak)) void handle_assertion(const char *expr, const char *file, int line) {

    fprintf(stderr, "ASSERTION FAILED: %s at %s:%d\n", expr, file, line);

    abort();

}

__attribute__((weak)) void handle_warning(const char *msg) {

    fprintf(stderr, "WARNING: %s\n", msg);

}

Applications can now override error handling:

// custom_error_handler.c

#include "error_handler.h"

#include <syslog.h>

void handle_fatal_error(const char *msg) {

    syslog(LOG_CRIT, "FATAL: %s", msg);

    // Send alert to monitoring system

    abort();

}

void handle_warning(const char *msg) {

    syslog(LOG_WARNING, "%s", msg);

}

Testing frameworks can intercept errors to verify proper error handling without crashing the test suite. Production builds use the default handlers. The library code remains unchanged—it just calls the error handling functions, and weak symbols ensure the right implementation is used.

Debugging Weak Symbol Issues

When weak symbols don’t behave as expected, you need tools to inspect which version is actually being used. This debugger helps you understand symbol resolution at runtime by checking where symbols come from and listing weak symbols in loaded libraries:

// weak_symbol_debugger.c

#include <stdio.h>

#include <dlfcn.h>

#include <link.h>

#include <string.h>

// Check symbol binding

void check_symbol(const char *name) {

    void *handle = dlopen(NULL, RTLD_NOW);

    if (!handle) return;

    void *symbol = dlsym(handle, name);

    if (symbol) {

        Dl_info info;

        if (dladdr(symbol, &info)) {

            printf("Symbol '%s':\n", name);

            printf("  Address: %p\n", symbol);

            printf("  From: %s\n", info.dli_fname);

            printf("  Base: %p\n", info.dli_fbase);

        }

    } else {

        printf("Symbol '%s': NOT FOUND\n", name);

    }

    dlclose(handle);

}

// List all weak symbols in current process

static int callback(struct dl_phdr_info *info, size_t size, void *data) {

    printf("\nLibrary: %s\n", info->dlpi_name[0] ? info->dlpi_name : "(main)");

    // Would need to parse ELF to list weak symbols

    // This is a simplified version

    char cmd[512];

    if (info->dlpi_name[0]) {

        snprintf(cmd, sizeof(cmd), "nm -D %s 2>/dev/null | grep ' [WV] ' | head -5", info->dlpi_name);

        system(cmd);

    }

    return 0;

}

int main(void) {

    printf("=== Weak Symbol Debugger ===\n");

    // Check some common weak symbols

    printf("\nChecking standard weak symbols:\n");

    check_symbol("__malloc_hook");

    check_symbol("__free_hook");

    check_symbol("environ");

    printf("\nWeak symbols in loaded libraries:\n");

    dl_iterate_phdr(callback, NULL);

    return 0;

}

Compile and run this debugger to inspect your program’s weak symbols:

$ gcc -o weak_debugger weak_symbol_debugger.c -ldl

$ ./weak_debugger

Output (Ubuntu 22.04)

=== Weak Symbol Debugger ===

Checking standard weak symbols:

Symbol '__malloc_hook':

  Address: 0x7ffff7fb2b00

  From: /lib/x86_64-linux-gnu/libc.so.6

  Base: 0x7ffff7dc5000

Symbol '__free_hook':

  Address: 0x7ffff7fb2b08

  From: /lib/x86_64-linux-gnu/libc.so.6

  Base: 0x7ffff7dc5000

Symbol 'environ':

  Address: 0x7ffff7fb3f48

  From: /lib/x86_64-linux-gnu/libc.so.6

  Base: 0x7ffff7dc5000

Weak symbols in loaded libraries:

Library: (main)

Library: /lib/x86_64-linux-gnu/libc.so.6

0000000000003e80 W __cxa_finalize

00000000001e6b00 V __malloc_hook

00000000001e6b08 V __free_hook

Output (Fedora 43)

=== Weak Symbol Debugger ===

Checking standard weak symbols:

Symbol '__malloc_hook':

  Address: 0x7ffff7fb2b00

  From: /lib64/libc.so.6

  Base: 0x7ffff7dc5000

Symbol '__free_hook':

  Address: 0x7ffff7fb2b08

  From: /lib64/libc.so.6

  Base: 0x7ffff7dc5000

Symbol 'environ':

  Address: 0x7ffff7fb3f48

  From: /lib64/libc.so.6

  Base: 0x7ffff7dc5000

Weak symbols in loaded libraries:

Library: (main)

Library: /lib64/libc.so.6

0000000000003e80 W __cxa_finalize

00000000001e6b00 V __malloc_hook

00000000001e6b08 V __free_hook

Note

Library paths differ between distributions (/lib/x86_64-linux-gnu/libc.so.6 on Ubuntu/Debian vs. /lib64/libc.so.6 on Fedora), but the symbols and addresses are consistent.

This tool is invaluable when you suspect a weak symbol is being overridden unexpectedly or when you need to verify that your override is actually being used. The addresses and library names tell you exactly which implementation the program will call.

Performance Implications

A common concern with weak symbols is whether they introduce runtime overhead. Let’s measure this empirically by comparing direct calls, weak symbol calls, and function pointer calls:

// weak_performance.c - Measure weak symbol overhead

#include <stdio.h>

#include <time.h>

// Direct function

int direct_function(int x) {

    return x * 2;

}

// Weak function

__attribute__((weak)) int weak_function(int x) {

    return x * 2;

}

// Function pointer

int (*func_ptr)(int) = direct_function;

#define ITERATIONS 100000000

double measure_calls(int (*func)(int), const char *name) {

    clock_t start = clock();

    volatile int result = 0;

    for (int i = 0; i < ITERATIONS; i++) {

        result = func(i);

    }

    clock_t end = clock();

    double time = ((double)(end - start)) / CLOCKS_PER_SEC;

    printf("%s: %.3f seconds (%.1f ns/call)\n",

           name, time, time * 1e9 / ITERATIONS);

    return time;

}

int main(void) {

    printf("=== Weak Symbol Performance ===\n");

    printf("Testing with %d iterations\n\n", ITERATIONS);

    measure_calls(direct_function, "Direct call");

    measure_calls(weak_function, "Weak call");

    measure_calls(func_ptr, "Function pointer");

    printf("\nConclusion: Weak symbols have no runtime overhead!\n");

    printf("(Resolution happens at link time)\n");

    return 0;

}

Compile and run this benchmark with optimizations enabled:

$ gcc -O2 -o weak_performance weak_performance.c

$ ./weak_performance

Output (Ubuntu 22.04, GCC 11.4.0)

=== Weak Symbol Performance ===

Testing with 100000000 iterations

Direct call: 0.126 seconds (1.3 ns/call)

Weak call: 0.175 seconds (1.7 ns/call)

Function pointer: 0.126 seconds (1.3 ns/call)

Conclusion: Weak symbols have no runtime overhead!

(Resolution happens at link time)

Output (Fedora 43, GCC 15.2.1)

=== Weak Symbol Performance ===

Testing with 100000000 iterations

Direct call: 0.126 seconds (1.3 ns/call)

Weak call: 0.126 seconds (1.3 ns/call)

Function pointer: 0.126 seconds (1.3 ns/call)

Conclusion: Weak symbols have no runtime overhead!

(Resolution happens at link time)

Note

Performance may vary slightly between compiler versions and optimization levels, but the key point remains: weak symbols have virtually identical performance to direct calls, confirming that resolution happens at link time, not runtime.

The results show that weak symbols have virtually identical performance to normal functions. This makes sense when you understand the mechanics: symbol resolution happens at link time (or load time for dynamic libraries), not at call time. Once resolved, calling a weak symbol is exactly the same as calling any other function—the CPU executes the same instructions either way.

The function pointer case shows only slightly higher overhead, and that’s due to the extra memory indirection, not the linkage type. For code within a single executable or shared library, weak symbols are essentially free at runtime.

Summary

Weak symbols are a powerful feature that enables flexible and extensible software design. Through our exploration, we’ve discovered the following:

1.

Weak symbols can be overridden by strong symbols during linking.

2.

Perfect for optional features: Check if a function exists before calling.

3.

Enable plug-in architectures without complex registration systems.

4.

Platform-specific optimizations can override generic implementations.

5.

No runtime overhead: Resolution happens at link time.

Key Insights from Real-World Usage

Use weak symbols for hardware abstraction layers.

Provide debug hooks that can be overridden in test builds.

Create libraries with optional dependencies.

Enable architecture-specific optimizations transparently.

Build extensible frameworks without tight coupling.

Best Practices

Don’t include headers with weak declarations in implementation files: This makes your definitions weak too, preventing overrides.

Always check weak function pointers for NULL before calling (for optional functions).

Document which symbols are weak and can be overridden.

Use meaningful default implementations.

Consider symbol visibility when creating shared libraries.

Test both with and without overrides.

Use nm or readelf to verify symbol types (T = strong, W = weak).

The behavior is consistent across all distributions, with only memory address differences reflecting different glibc versions and memory layout strategies. All code examples compile and run correctly on these platforms.

Weak symbols exemplify the elegance of Unix design—a simple mechanism that enables sophisticated patterns. They turn the linker from a mere symbol resolver into an active participant in creating flexible software architectures.

© Mohit Mishra 2026

M. MishraExecutable Files for Linuxhttps://doi.org/10.1007/979-8-8688-2154-7_14

14. Level Up Your C: Key Takeaways About Linking and Loading

Mohit Mishra¹  

(1)

Bangalore, Karnataka, India

We began this journey with a simple question: “Why is my ’Hello World’ so big?” That curiosity led us through the fascinating world of linking and loading in Linux, revealing layers of complexity and elegance in how programs come to life. Now, as we conclude our exploration, let’s consolidate our knowledge and see how these concepts work together to create the foundation of modern software.

The Complete Picture: From Source to Running Process

Let’s create a comprehensive example that demonstrates everything we’ve learned:

// ultimate_demo.c - Demonstrates all linking and loading concepts

#include <stdio.h>

#include <stdlib.h>

#include <dlfcn.h>

#include <string.h>

// Global variables demonstrating different sections

int initialized_global = 42;                    // .data section

int uninitialized_global;                       // .bss section

const char version[] = "Ultimate Demo v1.0";   // .rodata section

// Weak symbol for optional features

__attribute__((weak)) void advanced_feature(void) {

    printf("Basic feature (weak default)\n");

}

// Function to be placed in specific section

__attribute__((section(".fast_code")))

void performance_critical(void) {

    // This could be placed in fast memory

    printf("Performance critical code\n");

}

// Constructor and destructor

__attribute__((constructor))

static void init_function(void) {

    printf("Constructor: Program initializing (before main)\n");

}

__attribute__((destructor))

static void cleanup_function(void) {

    printf("Destructor: Program cleaning up (after main)\n");

}

// Dynamic loading demonstration

void dynamic_loading_demo(void) {

    printf("\n=== Dynamic Loading Demo ===\n");

    void *handle = dlopen("libm.so.6", RTLD_LAZY);

    if (!handle) {

        fprintf(stderr, "dlopen failed: %s\n", dlerror());

        return;

    }

    // Load a symbol dynamically

    double (*sin_func)(double) = dlsym(handle, "sin");

    if (sin_func) {

        printf("Dynamically called sin(1.0) = %f\n", sin_func(1.0));

    }

    dlclose(handle);

}

// Symbol visibility demonstration

__attribute__((visibility("hidden")))

void internal_function(void) {

    printf("This function is hidden from other modules\n");

}

__attribute__((visibility("default")))

void public_api_function(void) {

    printf("This function is part of the public API\n");

    internal_function();

}

int main(int argc, char *argv[]) {

    printf("\n=== Main Function Started ===\n");

    printf("Program: %s\n", argv[0]);

    printf("Version: %s\n", version);

    // Show memory layout

    printf("\n=== Memory Layout ===\n");

    printf("Code (.text):       %p (main function)\n", main);

    printf("Initialized (.data): %p = %d\n", &initialized_global, initialized_global);

    printf("Uninitialized (.bss): %p = %d\n", &uninitialized_global, uninitialized_global);

    printf("Read-only (.rodata): %p\n", version);

    printf("Stack:              %p (local variable)\n", &argc);

    void *heap = malloc(100);

    printf("Heap:               %p\n", heap);

    free(heap);

    // Test weak symbols

    printf("\n=== Weak Symbols ===\n");

    if (advanced_feature) {

        advanced_feature();

    }

    // Dynamic loading

    dynamic_loading_demo();

    // Show loaded libraries

    printf("\n=== Currently Loaded Libraries ===\n");

    system("cat /proc/self/maps | grep '\\.so' | awk '{print $6}' | sort -u | head -10");

    printf("\n=== Main Function Ending ===\n");

    return 0;

}

// advanced_override.c - Override weak symbols

#include <stdio.h>

void advanced_feature(void) {

    printf("Advanced feature (strong override)\n");

}

Compile and analyze:

# Compile with various options

$ gcc -o ultimate_demo ultimate_demo.c -ldl

$ gcc -o ultimate_demo_advanced ultimate_demo.c advanced_override.c -ldl

# Run both versions

$ ./ultimate_demo

Constructor: Program initializing (before main)

=== Main Function Started ===

Program: ./ultimate_demo

Version: Ultimate Demo v1.0

=== Memory Layout ===

Code (.text):       0x5555555551e9 (main function)

Initialized (.data): 0x555555558010 = 42

Uninitialized (.bss): 0x555555558040 = 0

Read-only (.rodata): 0x555555556008

Stack:              0x7fffffffd5bc (local variable)

Heap:               0x5555555592a0

=== Weak Symbols ===

Basic feature (weak default)

=== Dynamic Loading Demo ===

Dynamically called sin(1.0) = 0.841471

=== Currently Loaded Libraries ===

/lib/x86_64-linux-gnu/libc.so.6

/lib/x86_64-linux-gnu/libdl.so.2

/lib/x86_64-linux-gnu/libm.so.6

/lib64/ld-linux-x86-64.so.2

=== Main Function Ending ===

Destructor: Program cleaning up (after main)

Why does this matter? Because every element shown in this example represents design decisions, trade-offs, and possibilities:

Memory layout affects performance (locality), security (ASLR), and debugging.

Initialization sections let you run code before main() without explicit function calls.

Dynamic loading enables features that static linking makes impossible.

Weak symbols provide graceful defaults with optional overrides.

Visibility controls your API boundaries and namespace pollution.

Loaded libraries show the actual runtime dependencies.

Understanding these concepts transforms you from someone who just compiles code to someone who can debug linking failures, optimize startup performance, design flexible libraries, and solve subtle runtime issues that others find mystifying.

Key Concepts Integration Map

Throughout this book, we’ve explored individual components of the linking and loading process. Now, let’s see how they all fit together, starting with a high-level overview and then diving into each phase.

[picture]

Figure 14-1

Overall Flow from Source Code to Program Execution

Source Code to Object Files

The compilation phase transforms your human-readable C code into machine instructions. The compiler parses your source, performs optimizations, and generates relocatable object files. This whole journey of source code to object files is easily understandable from below figure.

[picture]

Figure 14-2

Source Code to Object Files Compilation Process

Each source file becomes an object file containing machine instructions, a symbol table listing all functions and variables defined or referenced, and relocation entries marking addresses that need adjustment during linking. The code is relocatable, meaning it can be placed at any address in memory once the linker determines the final layout.

Linking Phase

The linker combines multiple object files into a single executable. It resolves symbol references, assigns final addresses, and processes relocations. The linking strategy determines whether library code is embedded or referenced dynamically.

[picture]

Figure 14-3

Linking Phase: Static vs. Dynamic Linking Strategies

Above diagram shows the exact differentiation between Static and Dynamic linking. Static linking embeds all library code directly into the executable, creating a self-contained binary that requires no external dependencies. Dynamic linking creates stub entries in the PLT and GOT, records which libraries are needed, and specifies the dynamic linker that will resolve symbols at runtime. The choice affects executable size, memory usage, and deployment complexity.

Executable Structure

The resulting ELF file contains multiple components organized for efficient loading and execution. Headers describe the file structure, sections hold different types of data, and dynamic information guides the runtime linker.

[picture]

Figure 14-4

ELF Executable Structure and Organization

The ELF header identifies the file format and architecture. Program headers tell the kernel how to load the file into memory. Section headers describe the purpose and location of each section. Code lives in the text section, initialized variables in data, uninitialized variables in bss, and constants in rodata. For dynamically linked executables, additional sections contain the PLT stubs, GOT entries, and dynamic linking metadata.

Kernel Loading Process

When you execute a program, the kernel takes over. It validates the executable format, creates a new process, maps the program into memory, and prepares the execution environment before transferring control.

[picture]

Figure 14-5

Kernel Loading Process and Memory Mapping

Above diagrams shows the whole process as The kernel first validates that the file is a legitimate ELF executable with the correct magic number and architecture. It creates a new process with its own virtual address space, then maps the executable segments into memory with appropriate permissions: code is readable and executable, and data is readable and writable. The kernel allocates space for the stack and heap, sets up initial register values, and either jumps to the program entry point for static executables or loads the dynamic linker for dynamically linked programs.

Dynamic Linking at Runtime

For dynamically linked programs, the dynamic linker runs before your code. As we can see in Figure 14-6, It locates required libraries, loads them into memory, processes relocations, and resolves symbol references. This happens transparently every time you run the program.

[picture]

Figure 14-6

Dynamic Linking at Runtime: Library Loading and Symbol Resolution

The dynamic linker reads the executable’s dynamic section to determine which libraries are needed. It searches standard paths and LD_LIBRARY_PATH for each library, loads them into memory, and maps their segments. Relocations adjust addresses to account for where libraries actually loaded. Symbol resolution matches undefined references to definitions, updating GOT entries with actual addresses. With lazy binding, this happens on the first call; with eager binding, all symbols are resolved immediately. Finally, library constructors run to initialize global state before control transfers to your main function.

Program Execution

Once linking completes, your program finally runs. Constructors execute initialization code, your main function performs the program’s work, and destructors clean up before the process exits as we can see from below diagram.

[picture]

Figure 14-7

Program Execution Lifecycle: Initialization, Main Execution, and Cleanup

Constructors run in a specific order: library constructors execute first, followed by global object constructors in your program. These initialize static state, open resources, and prepare the runtime environment. Your main function then executes, performing the program’s actual work. Programs can dynamically load additional libraries with dlopen during execution, enabling plug-in systems and runtime extensibility. When main returns or exit is called, the cleanup phase begins: global destructors run in reverse order, followed by library destructors, closing files, freeing resources, and ensuring clean shutdown.

Practical Toolbox: Essential Commands

The tools we’ve explored throughout this book form a powerful toolkit for understanding and debugging linking issues. Here’s a quick reference organized by task.

Compilation and Linking

gcc -c source.c                    # Compile to object file

gcc -S source.c                    # Generate assembly

gcc -E source.c                    # Preprocess only

gcc -shared -fPIC -o lib.so lib.c  # Create shared library

gcc -static program.c              # Static linking

gcc -Wl,--verbose                  # Show linker script

gcc -Wl,-Map=output.map            # Generate map file

Analysis Tools

file program                       # Identify file type

ldd program                        # List dynamic dependencies

nm program                         # List symbols

objdump --disassemble program      # Disassemble code

objdump --headers program          # Show section headers

readelf --all program              # Comprehensive ELF info

readelf --symbols program          # Symbol table

readelf --dynamic program          # Dynamic section

Runtime Debugging

LD_DEBUG=libs ./program            # Debug library loading

LD_DEBUG=symbols ./program         # Debug symbol resolution

LD_DEBUG=bindings ./program        # Debug symbol binding

LD_PRELOAD=lib.so ./program        # Preload library

LD_LIBRARY_PATH=/path ./program    # Set library search path

strace -e openat,mmap ./program    # Trace system calls

ltrace ./program                   # Trace library calls

Performance Analysis

time ./program                     # Basic timing

perf record ./program              # Record performance

perf report                        # Analyze performance

valgrind ./program                 # Memory analysis

Best Practices Checklist

Applying the concepts from this book requires discipline and attention to detail. These best practices distill lessons learned from production systems.

Library Design

Use symbol visibility attributes to control which functions and variables are exported from your shared libraries. Version your symbols to maintain backward compatibility as your library evolves. Provide weak symbols for optional features that users can override. Document clearly which symbols constitute your public API and which are internal implementation details. Choose meaningful SONAME values that reflect your versioning scheme.

Security

Clear LD_PRELOAD and LD_LIBRARY_PATH in setuid programs to prevent privilege escalation attacks. Validate these environment variables in any security-critical context. Use BIND_NOW to resolve all symbols at load time, eliminating the attack surface of lazy binding. Enable RELRO (RELocation Read-Only) to prevent GOT overwrite attacks. For high-security applications, implement library signature verification to ensure only trusted code is loaded.

Performance

Use lazy binding for faster startup times, deferring symbol resolution until functions are actually called. Group related functions into custom sections to improve cache locality and reduce instruction cache misses. Minimize exported symbols to reduce PLT overhead and speed up symbol resolution. Consider prelinking for embedded systems where startup time is critical. Profile with LD_DEBUG=statistics to identify bottlenecks in the linking process.

Debugging

Generate symbol files for production binaries to enable meaningful crash reports and debugging without shipping debug information to users. Use meaningful symbol names that survive stripping and help identify functions in stack traces. Include debug sections in development builds for comprehensive debugging with gdb. Test with different LD_DEBUG options to understand how your program loads and links. Maintain symbol maps for each release to decode crash reports from the field.

Portability

Use feature detection rather than platform checks to handle differences between systems. Provide fallback implementations for missing symbols using weak symbols or runtime detection. Test on multiple architectures to catch endianness issues and alignment problems. Handle endianness explicitly in binary formats and network protocols. Document the minimum library versions required by your software to help users diagnose compatibility issues.

Real-World Application: Building a Plug-In System

Throughout this book, we’ve explored linking concepts individually. Let’s now build a complete plug-in system that demonstrates how these concepts work together in real software. This system uses dynamic linking for plug-in loading, weak symbols for customization points, and symbol visibility for API boundaries:

// plugin_system.h

#ifndef PLUGIN_SYSTEM_H

#define PLUGIN_SYSTEM_H

#include <stddef.h>

// Plugin interface

typedef struct {

    const char *name;

    const char *version;

    int (*initialize)(void);

    void (*execute)(const char *command);

    void (*cleanup)(void);

} PluginInterface;

// Plugin manager functions

int plugin_manager_init(void);

int plugin_manager_load(const char *path);

int plugin_manager_execute(const char *plugin_name, const char *command);

void plugin_manager_list(void);

void plugin_manager_cleanup(void);

// Weak symbols for customization

__attribute__((weak)) void plugin_log(const char *format, ...);

__attribute__((weak)) void *plugin_allocate(size_t size);

__attribute__((weak)) void plugin_free(void *ptr);

#endif

// plugin_system.c

#include "plugin_system.h"

#include <stdio.h>

#include <stdlib.h>

#include <string.h>

#include <dlfcn.h>

typedef struct Plugin {

    void *handle;

    PluginInterface *interface;

    char *path;

    struct Plugin *next;

} Plugin;

static Plugin *plugins = NULL;

// Weak default implementations

__attribute__((weak)) void plugin_log(const char *format, ...) {

    va_list args;

    va_start(args, format);

    vprintf(format, args);

    va_end(args);

}

__attribute__((weak)) void *plugin_allocate(size_t size) {

    return malloc(size);

}

__attribute__((weak)) void plugin_free(void *ptr) {

    free(ptr);

}

int plugin_manager_init(void) {

    plugin_log("Plugin manager initialized\n");

    return 0;

}

int plugin_manager_load(const char *path) {

    plugin_log("Loading plugin: %s\n", path);

    // Open the plugin

    void *handle = dlopen(path, RTLD_LAZY | RTLD_LOCAL);

    if (!handle) {

        plugin_log("Failed to load %s: %s\n", path, dlerror());

        return -1;

    }

    // Get the plugin interface

    PluginInterface *(*get_plugin_interface)(void);

    get_plugin_interface = dlsym(handle, "get_plugin_interface");

    if (!get_plugin_interface) {

        plugin_log("Invalid plugin %s: %s\n", path, dlerror());

        dlclose(handle);

        return -1;

    }

    // Create plugin entry

    Plugin *plugin = plugin_allocate(sizeof(Plugin));

    plugin->handle = handle;

    plugin->interface = get_plugin_interface();

    plugin->path = strdup(path);

    plugin->next = plugins;

    plugins = plugin;

    // Initialize the plugin

    if (plugin->interface->initialize) {

        int result = plugin->interface->initialize();

        if (result != 0) {

            plugin_log("Plugin initialization failed: %s\n",

                      plugin->interface->name);

            dlclose(handle);

            plugin_free(plugin);

            return -1;

        }

    }

    plugin_log("Loaded plugin: %s v%s\n",

              plugin->interface->name,

              plugin->interface->version);

    return 0;

}

void plugin_manager_list(void) {

    plugin_log("\n=== Loaded Plugins ===\n");

    Plugin *current = plugins;

    while (current) {

        plugin_log("- %s v%s (%s)\n",

                  current->interface->name,

                  current->interface->version,

                  current->path);

        current = current->next;

    }

}

// example_plugin.c - Sample plugin

#include "plugin_system.h"

#include <stdio.h>

static int example_init(void) {

    printf("Example plugin initializing...\n");

    return 0;

}

static void example_execute(const char *command) {

    printf("Example plugin executing: %s\n", command);

}

static void example_cleanup(void) {

    printf("Example plugin cleaning up...\n");

}

static PluginInterface example_interface = {

    .name = "Example Plugin",

    .version = "1.0.0",

    .initialize = example_init,

    .execute = example_execute,

    .cleanup = example_cleanup

};

PluginInterface *get_plugin_interface(void) {

    return &example_interface;

}

Build and use this plug-in system:

# Compile the plugin

$ gcc -shared -fPIC example_plugin.c -o example_plugin.so

# Compile the main program with the plugin system

$ gcc -c plugin_system.c

$ gcc -o app main.c plugin_system.o -ldl

# Run the application

$ ./app

Plugin manager initialized

Loading plugin: ./example_plugin.so

Example plugin initializing...

Loaded plugin: Example Plugin v1.0.0

=== Loaded Plugins ===

- Example Plugin v1.0.0 (./example_plugin.so)

This system demonstrates several key concepts. Dynamic linking via dlopen loads plug-ins at runtime without requiring recompilation of the host. Weak symbols (plugin_log, plugin_allocate, plugin_free) provide default implementations that sophisticated users can override. The RTLD_LOCAL flag prevents plug-ins from polluting each other’s symbol namespaces. The plug-in interface structure provides a stable ABI that decouples plug-in development from host application changes.

Real applications like web browsers, text editors, and media players use variations of this architecture to support extensibility without sacrificing stability.

Performance Optimization Strategies

Armed with knowledge of linking and loading, you can apply targeted optimizations. Let’s explore techniques that leverage linker behavior for better performance:

// optimization_demo.c - Performance optimization techniques

#include <stdio.h>

#include <time.h>

// Group related functions together

__attribute__((section(".hot_code")))

void hot_function1(void) { /* Frequently called */ }

__attribute__((section(".hot_code")))

void hot_function2(void) { /* Frequently called */ }

__attribute__((section(".cold_code")))

void error_handler(void) { /* Rarely called */ }

// Use visibility to reduce symbol table size

__attribute__((visibility("hidden")))

static void internal_helper(void) { /* Not exported */ }

// Optimize for size in rarely-used functions

__attribute__((optimize("Os")))

void rarely_used_function(void) {

    printf("This function is optimized for size\n");

}

// Force inlining for critical paths

__attribute__((always_inline))

static inline int fast_operation(int x) {

    return x * 2 + 1;

}

// Measure the impact

void measure_optimizations(void) {

    printf("=== Optimization Measurements ===\n");

    // Measure function call overhead

    const int iterations = 100000000;

    clock_t start = clock();

    volatile int result = 0;

    for (int i = 0; i < iterations; i++) {

        result = fast_operation(i);

    }

    clock_t end = clock();

    double time = ((double)(end - start)) / CLOCKS_PER_SEC;

    printf("Inlined function: %.3f seconds for %d calls\n", time, iterations);

    printf("Time per call: %.1f nanoseconds\n", time * 1e9 / iterations);

}

These optimizations leverage linker features we’ve covered. Grouping hot functions into .hot_code improves instruction cache locality—the CPU prefetches code more effectively when related functions are adjacent in memory.

Separating cold error handling code prevents it from polluting the instruction cache during normal execution. Hidden visibility reduces PLT overhead because the compiler knows those symbols can’t be interposed. Size optimization (Os) on rarely used functions frees up cache space for hot code. Forced inlining eliminates call overhead entirely for tiny functions.

Compile with a custom linker script that places hot code strategically:

$ gcc -O2 -o optimized optimization_demo.c

$ ./optimized

=== Optimization Measurements ===

Inlined function: 0.285 seconds for 100000000 calls

Time per call: 2.9 nanoseconds

The 2.9 nanoseconds per call time reflects successful inlining—there’s no actual function call happening, just the arithmetic operation. Without always_inline, the compiler might choose not to inline in some contexts, costing five to ten nanoseconds per call due to call/return overhead.

Future Directions and Advanced Topics

Your journey into linking and loading has just begun. The foundations we’ve covered enable exploration of advanced topics that push the boundaries of systems programming. Here are areas where linking concepts become even more sophisticated:

Link-Time Optimization (LTO) enables the compiler to see across compilation units, performing optimizations that single-file compilation cannot. With gcc -flto -O3, the compiler can inline functions from other files, eliminate dead code globally, and propagate constants across modules. This can yield 10–20% performance improvements but requires understanding how the linker and compiler collaborate.

Custom Dynamic Linkers allow you to implement specialized loading behavior. Security-conscious systems might write a hardened dynamic linker that validates cryptographic signatures on libraries before loading. Real-time systems might implement a linker that locks all code pages in RAM to prevent page faults. The ld.so source code in glibc shows how this layer works.

Binary Instrumentation modifies programs at runtime for profiling or security monitoring. Tools like Intel Pin or DynamoRIO intercept function calls, insert counters, or redirect execution. Understanding PLT/GOT mechanics is essential for this work—you’re essentially hooking the linking process.

Advanced Relocation Types handle special cases like thread-local storage (each thread gets its own copy of a variable) or copy relocations (avoiding indirection for frequently accessed data). The ELF specification defines dozens of relocation types, each solving a specific problem.

Static position-independent executables (PIE) combine the security benefits of ASLR with the simplicity of static linking. Modern Linux distributions often enable PIE by default for hardened security, but this imposes performance costs that sophisticated developers can mitigate.

Kernel Module Loading applies these concepts to kernel space. When you insmod a kernel module, the kernel’s linker resolves symbols, processes relocations, and manages dependencies—all without userspace tools. The mechanisms mirror userspace linking but with kernel-specific constraints.

These advanced topics build on everything we’ve covered. The concepts remain the same: symbols, relocations, sections, and linking. Only the applications become more sophisticated. The knowledge you’ve gained provides the foundation for mastering these advanced areas.

Conclusion: The Power of Understanding

Through our journey from that first “Hello, World!” to complex plug-in systems, we’ve uncovered the sophisticated machinery that brings programs to life. We’ve learned that

1.

Every Byte in an Executable Has a Purpose: From ELF headers to initialization code, nothing is wasted.

2.

Dynamic Linking Provides Incredible Flexibility: Code sharing, lazy loading, and runtime binding create efficient systems.

3.

The Kernel and Dynamic Linker Work in Harmony: A carefully choreographed handoff enables modern application architectures.

4.

Simple Mechanisms Enable Complex Patterns: Weak symbols, symbol visibility, and environment variables provide powerful capabilities.

5.

Performance and Security Require Deliberate Choices: Understanding the trade-offs helps us make informed decisions.

This knowledge empowers you to

Debug mysterious crashes by understanding symbol resolution.

Optimize application startup through intelligent use of lazy loading.

Create flexible architectures with plug-ins and weak symbols.

Secure your applications by controlling the linking environment.

Profile and improve performance using the right tools and techniques.

The elegance of the Unix philosophy shines through in linking and loading: simple, composable tools and concepts that combine to create powerful systems. Whether you’re building embedded systems, high-performance servers, or desktop applications, these fundamentals remain constant.

As you continue your journey in systems programming, remember that every gcc invocation, every ldd output, and every mysterious symbol error is an opportunity to deepen your understanding. The tools and concepts we’ve explored are not just academic exercises—they’re the foundation of every program running on Linux systems worldwide.

Happy coding and may your symbols always resolve!

Index

A

Absolute relocations¹¹⁶

Address Space Layout Randomization (ASLR)³⁹, 40, 63, 117

Advanced environment variables

LD_DYNAMIC_WEAK222–223

LD_SHOW_AUXV220–222

LD_TRACE_LOADED_OBJECTS²²²

Advanced linker script features

exporting linker symbols¹⁵³, 154

separating critical code¹⁴⁹, 151, 152

Advanced linking techniques

common symbols¹⁰²

version scripts¹⁰¹

weak symbols¹⁰²

Advanced Relocation Types³¹⁷

copy relocations¹¹⁵

GOT¹¹⁵

Advanced symbol properties

symbol versioning131–133

Affects performance (locality), security (ASLR)²⁹⁷

ALIGN()¹⁵⁵

ALIGN function¹⁴⁹

Alignment (ALIGN directive)¹⁵⁷

another_hot_function¹⁵²

arch_prctl()²³³

Architecture-specific optimizations275–279

Assembler output (a.out) fomat²⁰

Auxiliary vector241, 242, 256

B

Best practices

debugging³⁰⁹

library design³⁰⁸

for linking

global variables¹⁰³

header guards¹⁰³

organize libraries logically¹⁰³

static for internal functions¹⁰³

performance³⁰⁸

portability³⁰⁹

security³⁰⁸

Binary format²³⁸, 245, 256

Binary Instrumentation³¹⁶

C

CFI

SeeControl Flow Integrity (CFI)

-c flag⁹⁵

COFF

SeeCommon Object File Format (COFF)

Cold error handling code³¹⁶

Common linking errors¹⁰⁵

Common (C) notation¹²⁸

Common Object File Format (COFF)²⁰, 46

Common relocation issues¹²⁰

Common relocation types

absolute relocations (R_X86_64_32S)¹¹⁰

PC-relative relocations (R_X86_64_PC32)¹¹⁰

PLT relocations (R_X86_64_PLT32)¹¹⁰

Common symbol issues¹³⁴, 135

Common symbols¹⁰², 128, 129

Control Flow Integrity (CFI)⁴⁷, 75

Controlling lazy loading behavior

dlopen with different flags¹⁹⁴

forcing immediate binding192–194

Copy relocations¹¹⁵

Custom Dynamic Linkers³¹⁶

Custom linker script creation¹⁴⁵, 147, 148

D

Data structures¹⁸⁸

Dead Code Elimination¹⁰⁴

Debugging²²⁹

linker scripts

diagnostic tool¹⁵⁴

issues and solutions¹⁵⁵

kernel loading issues250–253

relocation issues

position-independent code¹¹⁴

text relocations¹¹⁴

undefined references¹¹⁴

Debug linking failures²⁹⁷

Demand paging²⁴³

Dynamic library path caching226–228

Dynamic linking

build and deploy software¹⁸³

common C library functions159–162

creating shared library

code¹⁶²

compile-time linking¹⁶⁵, 166

flag¹⁶⁴

library path problem¹⁶⁶, 167

--shared flag¹⁶⁵

debugging issues

common problems and solutions182

useful tools¹⁸³

library design

initialization and cleanup181, 182

proper symbol visibility181

library injection attacks¹⁷⁹, 180

process

GOT and PLT¹⁶⁸, 169

real time scenario¹⁶⁹, 170

runtime dynamic loading with dlopen¹⁷¹, 173, 174

symbol versioning174–176

protecting against injection¹⁸⁰

static vs. dynamic linking176–178

Dynamic loader library (libdl)¹⁹¹

Dynamic loading²⁹⁷

Dynamic symbol table (.dynsym)¹³⁹

E

ELF

SeeExecutable and Linkable Format (ELF)

ELF header²³

counts and sizes²⁶

entry point²⁶

file type²⁴, 25

header locations²⁶

machine architecture²⁵

magic number²⁴

program²³

ELF sections³⁰, 31, 50, 54, 75

.bss section⁶⁰, 61

NOBITS type⁶⁰

section permissions⁶³

size⁶⁰

.data section⁵⁸

memory efficiency⁵⁹

section permissions⁶³

vs. .text and .rodata⁵⁹

.rodata section

example⁵⁷

jump tables⁵⁸

objdump⁵⁷

occupants⁵⁷

output⁶⁶

section permissions⁶³

string constant⁵⁷

switch statements⁵⁸

and .text section⁵⁷

.text section

considerations⁵⁶

example⁵⁴

objdump⁵⁵

order of functions⁵⁶

output⁶⁶

section permissions⁵⁶, 63

code³², 33

concepts⁷⁶

data³³, 34

debugging⁶⁸

dynamic linking sections⁶⁷

example⁶⁴, 65

initialization and finalization⁶⁷

innovations⁷⁵

issues

data executable⁷¹

large arrays⁷⁰

section placement, GCC⁷¹, 72

string literals modification⁷⁰

memory layout61–63

practical techniques

examining section sizes⁶⁸, 69

inspecting section permissions⁷⁰

stripping unnecessary sections⁶⁹

program loads and runs⁷⁴

real-world programs⁷⁴

relocation³⁴, 35

relocation section⁶⁷

section permissions⁶³

flags column⁶³

security⁶⁴, 73

vs.segments⁵³, 54

special sections³⁵

symbol³⁴, 35

symbol section⁶⁷

tools⁷⁶

Environment variables

advanced variables

SeeAdvanced environment variables

comprehensive dynamic linking debugger²²³

dependency issue²⁰⁷

dynamic linker²²⁸

dynamic linking²⁰⁷

LD_BIND_NOW²¹⁸

LD_DEBUG²¹³

LD_LIBRARY_PATH208–212

LD_PRELOAD

SeeLD_PRELOAD

performance optimization patterns226–228

security best practices224

Executable and Linkable Format (ELF)¹, 3, 19

compilation process³⁶, 51

dynamic executable³⁷, 38

object file³⁶

shared library³⁷

static executable³⁷, 38

components³

core dumps⁴⁶

create program²¹

custom data⁴¹, 42

debug information⁴²

dependencies⁴²

disassembling functions⁴³, 44

dynamic symbol table⁴³

executable files⁴⁵

executables¹⁶, 77

executable structure and organization³⁰²

format¹⁰⁴, 238

fundamentals⁴⁹

future directions⁴⁷

header³, 4

historical evolution⁴⁶

history²⁰

Linux¹⁹, 20, 51

modern features⁴⁷

parts²²

real-world program⁴⁸, 49

relocatable files (.o)⁴⁵

relocations³⁸

security implications⁵⁰

memory permissions³⁹

PIE³⁹, 40

RELRO⁴⁰, 41

stack canaries⁴⁰

W^X³⁹

shared objects (.so)⁴⁵

system calls⁴⁴

text representation²²

tools and analysis⁵¹

views²³

Executable file¹

Executables

code segments⁴

contents¹, 3

data segments⁴

debug programs¹⁷

dynamic linking¹⁴, 17

infrastructure⁵

lifecycle¹⁵, 16

optimization techniques¹², 17

advanced optimization¹³

basic size optimization¹³

static linking¹³

platform compatibility benefits¹⁴

runtime infrastructure benefits¹⁴

runtime support⁶

sections

.rodata section⁸

.text section⁷, 8

dynamic linking⁸

dynamic symbol⁹

objdump⁶, 7

runtime support⁹, 10

size breakdown10–12

execve()²³²

execve system call²³³, 234

F

Feature detection¹⁷³

Format validation²³⁸

-fPIC flag¹⁶⁴

Function pointer case²⁹¹

Function pointers²⁶³

G

get_timestamp²⁶⁰

Global Offset Table (GOT)⁴⁰, 41, 73, 168, 189

Global variables⁷⁷

best practices⁸⁷, 88, 91

concepts⁹¹

constructor function⁸², 83

debugging⁸⁸

destructor function⁸², 83

ELF sections⁸⁰

GDB⁷⁸, 79

initialization⁷⁸, 90

constant initialization⁸⁰, 91

dynamic⁹¹

dynamic initialization⁸¹, 82, 91

static⁹¹

zero initialization⁸¹, 91

optimization strategies⁸⁹

pitfalls

circular dependencies⁸⁶, 87

dynamic allocation, cleanup84–86

initialization order dependencies⁸³, 84

security implications⁸⁹, 90

startup sequence⁷⁹, 80

storage⁸²

GOT

SeeGlobal Offset Table (GOT)

GPIO-specific functions²⁶⁹

H

HALs

SeeHardware abstraction layers (HALs)

Hardware abstraction layers (HALs)²⁶⁶, 268, 269

"Hello, World!" program¹, 2, 16

I, J

IFUNC relocations¹¹⁵

Initialization²⁹⁷

Integration map

dynamic linking at runtime303–305

ELF executable structure³⁰²

kernel loading process³⁰³

linking phase³⁰⁰, 302

program execution³⁰⁵, 306

source code to object files298–300

Intel Pin³¹⁶

Internal functions¹³⁴

K

Kernel loading process238–243, 303

Kernel module loading³¹⁷

L

Lazy loading

dynamic linker¹⁸⁷

GOT¹⁸⁹

necessity¹⁸⁵

observation¹⁸⁹, 191, 192

optimization²⁰⁵

performance impact²⁰⁰, 201

PLT¹⁸⁸

plug-in system¹⁹⁵, 200

profiling symbol resolution204

resolution process¹⁹²

security implications203–204

LD_BIND_NOW²¹⁸, 228

LD_BIND_NOW security pattern203–204

LD_DEBUG²¹³, 228

LD_DEBUG=libs²²⁸

LD_DYNAMIC_WEAK222–223

LD_LIBRARY_PATH²²⁸, 305, 308

additional directories²¹⁰

dynamic linker variable²⁰⁸

real problem²¹¹

security risk²¹²

security warning212–213

LD_PRELOAD²²⁸, 308

dynamic linker²¹⁴

function interception²¹⁴

library¹³⁷

security monitoring²¹⁶, 217

testing with mock functions217

usage²¹⁴

LD_SHOW_AUXV220–222

LD_TRACE_LOADED_OBJECTS²²²

libc library¹⁶²

Library injection attacks¹⁷⁹, 180

Linker

advanced techniques

SeeAdvanced linking techniques

calculator library⁹³

common issues and solutions

multiple definitions⁹⁸, 99

undefined references⁹⁸

compilation process⁹⁵, 96

dead code elimination¹⁰⁴

definition⁹³

ELF object files⁹³

linking process⁹⁶

LTO¹⁰³

profile-guided optimization¹⁰⁴

relocations⁹⁷, 98

scripts

configuration files¹⁵⁶

impact performance¹⁵⁵, 156

memory layout143–145

memory map blueprint¹⁴⁵

static libraries⁹⁹

symbol resolution⁹⁷

symbol resolution rules¹³⁰

verbose output¹⁰⁰, 101

Linker-provided symbols¹⁵⁴

Linking and loading

comprehensive sample²⁹³

Linking process¹⁰⁴, 105

Linking strategy³⁰⁰

Link-Time Optimization (LTO)⁷⁵, 93, 100, 103, 316

Linux¹⁹, 20, 51, 77

Linux’s executable loading

binary format detection245–247

command to execution231–233

debug common loading problems250–253

execve system call²³³, 234

kernel loading behavior235–238

kernel orchestration²⁵⁶

Kernel’s loading process238–243

key insights²⁵⁶

memory mapping243–245

performance implications²⁵⁴, 255

sophisticated system²⁵⁶

VDSO248–250

LOCAL symbols¹²⁶

LTO

SeeLink-Time Optimization (LTO)

M, N, O

Mach-O format¹⁹

malloc()¹³⁷

MEMORY block¹⁴⁸

Memory debugging²⁶⁹, 271–275

Memory layout143–145

Memory map blueprint¹⁴⁵

Memory mapping³⁰³

Memory region¹⁴⁸

Mixed PIC and Non-PIC Code¹¹⁹

Modern security features¹²⁰

P, Q

PC-relative relocations¹¹⁶

Performance optimization strategies314–316

performance_critical_function¹⁵²

Performance-sensitive code¹⁴⁹

PGO

SeeProfile-guided optimization (PGO)

Physical library¹⁶²

PIE

SeePosition-independent executables (PIE)

PLT

SeeProcedure Linkage Table (PLT)

PLT relocations¹¹⁶

PLT relocations (R_X86_64_PLT32)¹¹⁰

Plug-in system¹⁷³, 195, 200, 309, 312–314

POPCNT instruction²⁷⁹

Portable Executable (PE) format¹⁹

Position-independent code¹²⁰

Position-independent executables (PIE)²⁵, 39, 40, 50

Practical applications of weak symbols

architecture-specific optimizations275–279

best implementation²⁷⁹

HALs²⁶⁶, 268, 269

memory debugging²⁶⁹, 271–275

Practical linker script concepts

load address vs. runtime address¹⁴⁹

memory region¹⁴⁸

sections¹⁴⁸, 149

Practical symbol debugging

common issues¹³⁴, 135

symbol interposition¹³⁶, 137

Practical toolbox

analysis tools³⁰⁷

compilation and linking³⁰⁷

performance analysis³⁰⁸

runtime debugging307–308

printf-related code¹⁵⁷

Procedure Linkage Table (PLT)¹⁶⁸, 188

Profile-guided optimization (PGO)⁵⁶, 72

Program execution lifecycle³⁰⁶

Program headers²⁷

DYNAMIC segment²⁹

example²⁷

INTERP segment²⁸

LOAD segment²⁹

PHDR segment²⁸

segments⁵⁰

Program’s memory layout¹⁵⁶

R

RELocation Read-Only (RELRO)⁴⁰, 41, 73, 117

Relocations¹⁰⁵

analysis tools

advanced analysis118–119

essential commands118

best practices

Group-related data¹¹⁷

minimize global variables¹¹⁷

position-independent code¹¹⁷

calculation methods

absolute relocations¹¹³

PC-relative relocations¹¹³

PLT relocations¹¹⁴

detailed analysis110–112

ELF format¹⁰⁷

linkers¹¹², 113

in object files¹⁰⁹

optimization techniques

LTO¹¹⁹

profile-guided optimization¹¹⁹

performance implications¹¹⁶

placeholders¹⁰⁷

security considerations

ASLR¹¹⁷

RELRO¹¹⁷

truncation¹¹⁹

types¹⁰⁸, 109

RELRO

SeeRELocation Read-Only (RELRO)

RTLD_LAZY flag¹⁷⁴

RUNPATH¹⁶⁷

S

Sections¹⁴⁸, 149

Security-conscious systems³¹⁶

--shared flag¹⁶⁵

Shared object¹⁶⁵

Size optimization (Os)³¹⁶

Sophisticated container³

Sophisticated machinery³¹⁷

Source code to object files compilation process299

Static libraries⁹⁹

Static position-independent executables (PIE)³¹⁷

Static symbol table (.symtab)¹³⁹

Static vs. dynamic linking strategies301, 302

Symbols

addressing system¹²³

common symbols¹²⁸, 129

error³¹⁸

fundamental mechanism¹⁴²

linker errors¹⁴²

interposition¹³⁶, 137

Ndx field¹²⁶

performance impacts¹³⁹, 141

program code123–125

resolutions129–131, 308

shared libraries¹³⁸, 139

versioning⁹³, 131–133, 174–176

visibility¹³⁴

control¹³³, 134

levels126–128

T

test_resolution.c²⁸¹

Testing frameworks²⁸⁵

U

Undefined references⁹⁸

Unix philosophy³¹⁸

V

VDSO

SeeVirtual Dynamic Shared Object (VDSO)

Version scripts¹⁰¹

Version selection¹⁷³

Virtual Dynamic Shared Object (VDSO)248–250, 256, 257

W, X, Y, Z

Weak symbols¹⁰²

debugging issues285–288

default implementations³¹⁴

definition²⁶¹

error handling strategies283–285

flexible and extensible software design²⁹¹

importance²⁵⁹, 260

library initialization281–283

linker-level solution²⁶³

optional overriding²⁶¹

performance implications288–291

practical applications

SeePractical applications of weak symbols

symbol resolution rules279–281

tool creation263–266

Unix design²⁹²

Weak symbols (W/V)¹²⁸