Chapter 90: Shared Libs: Linking, LD_LIBRARY_PATH, rpath, & ldconfig

Chapter Objectives

Upon completing this chapter, you will be able to:

  • Understand the fundamental differences between static and dynamic linking and articulate the trade-offs for embedded systems.
  • Implement a shared library and an application that dynamically links against it using the GCC toolchain.
  • Configure the runtime search path for shared libraries using LD_LIBRARY_PATH, the rpath mechanism, and standard system directories.
  • Utilize core system utilities like lddldconfig, and readelf to inspect, debug, and manage shared library dependencies on an embedded target.
  • Analyze and resolve common runtime linking errors, such as “cannot open shared object file.”
  • Deploy an application with its shared library dependencies correctly on an embedded Linux system like the Raspberry Pi 5.

Introduction

In the landscape of embedded systems, resource management is paramount. Every byte of storage and every clock cycle of the CPU is a precious commodity. This principle directly influences how we build and deploy software. While early chapters may have focused on creating single, monolithic executables, this approach quickly becomes inefficient as system complexity grows. Imagine an embedded system with dozens of applications, each needing to perform a similar task, such as decompressing a video stream or communicating over a network. Statically linking the same code into every application would be a colossal waste of flash memory and RAM. This is the problem that dynamic linking and shared libraries were designed to solve.

A shared library is a collection of compiled code—functions, data, and resources—that is loaded into memory at runtime and can be used by multiple processes simultaneously. Instead of duplicating common code in every executable, a single copy of the library resides on the filesystem and in memory, leading to significant reductions in storage and RAM usage. This chapter delves into the entire lifecycle of using shared libraries in an embedded Linux context. We will move beyond the simple act of compilation and explore the crucial runtime mechanisms that allow the system to locate, load, and link these libraries on the fly. You will learn how the dynamic linker, the unsung hero of the runtime environment, finds these dependencies and how you, the developer, can control its behavior. Using the Raspberry Pi 5 as our practical platform, we will build, deploy, and debug applications that rely on shared libraries, mastering the essential tools and techniques required for creating efficient, modular, and maintainable embedded systems.

Technical Background

To truly appreciate the elegance and efficiency of shared libraries, one must first understand the foundation upon which they are built: the process of linking. The journey from human-readable source code to an executable program involves several stages, with linking being the final, critical step where disparate pieces of code are woven together into a coherent whole.

From Static to Dynamic Linking: An Evolutionary Tale

In the early days of computing, the prevailing method was static linking. The linker, a key component of the toolchain, would resolve all symbolic references at compile time. It would physically copy all the required library code from files (typically with a .a extension, for “archive”) and merge it directly into the final executable file. The result was a single, large, self-contained binary. For simple embedded systems, this approach has the distinct advantage of simplicity and predictability. The executable has no external dependencies; everything it needs to run is baked in. Deployment is a simple matter of copying one file to the target.

However, this simplicity comes at a significant cost, especially as systems scale. If ten different applications on your device all use the popular zlib compression library, ten separate copies of that library’s code will be embedded in those ten executables, consuming redundant space on your storage medium. When these applications run, ten copies of that same code might be loaded into RAM, wasting precious memory. Furthermore, updating a bug in the library requires recompiling and redeploying all ten applications—a maintenance nightmare.

These challenges gave rise to dynamic linking. The core idea is to defer the linking of library code until the program is actually run. Instead of copying the library’s code into the executable, the linker places a small stub or placeholder. This placeholder essentially says, “at runtime, I will need the function named some_function from the library named libfoo.so.” The .so extension stands for “shared object,” the standard format for shared libraries on Linux.

When the user executes the program, the operating system’s loader doesn’t just load the executable into memory. It first inspects the executable’s header to see what shared libraries it needs. It then invokes a special program known as the dynamic linker (or runtime linker), which on most Linux systems is ld.so or ld-linux.so. This linker’s job is to find the required .so files on the filesystem, load them into memory, and then perform the final linking process right there in RAM. This process, known as symbol resolution, involves patching the application’s code to point to the actual memory addresses of the functions and variables in the loaded library.

Feature Static Linking Dynamic Linking
Executable Size Larger. All library code is copied into the final binary. Smaller. Contains only stubs/references to external libraries.
Memory Usage (RAM) Higher. If multiple apps use the same library, multiple copies are loaded into RAM. Lower. A single copy of a shared library is loaded and shared among all processes.
Deployment Simpler. The executable is self-contained with no external dependencies. More complex. Requires deploying the executable and all its .so dependencies.
Update Process Difficult. A bug fix in a library requires recompiling and redeploying all applications using it. Easier. A library update only requires replacing the single .so file on the system.
Initial Load Time Potentially faster, as all symbols are resolved at compile time. Slightly slower, due to the runtime work of the dynamic linker (ld.so) to find and load libraries.
Flexibility Low. The entire application is a single, monolithic block. High. Allows for modular systems and plugin-like architectures.
Typical Use Case Very simple, resource-constrained microcontrollers or scenarios where dependencies must be eliminated. Standard for modern operating systems, including almost all embedded Linux systems.

The beauty of this approach is that if ten applications all need libfoo.so, the dynamic linker loads only one copy of libfoo.so into physical RAM. The kernel’s memory management then maps this single copy into the virtual address space of all ten processes. This results in a massive saving of both storage and memory. A security update or bug fix to libfoo.so now only requires replacing a single file on the filesystem; all applications that use it will benefit from the update the next time they are launched, without needing to be recompiled.

The Anatomy of a Dynamic Link: ELF, PLT, and GOT

To make dynamic linking work, the executable and shared library files must contain extra information. On modern Linux systems, this is governed by the Executable and Linking Format (ELF). An ELF file is structured into sections, each holding different kinds of data: .text for program code, .data for initialized variables, and so on. For dynamic linking, several special sections are crucial.

The .interp section contains a path to the dynamic linker itself, typically /lib/ld-linux-aarch64.so.1 on a 64-bit ARM system like the Raspberry Pi 5. This tells the kernel which program to invoke to handle the dynamic linking process. The .dynamic section contains a list of key-value pairs, including an entry of type DT_NEEDED for each required shared library.

But how does the application’s code, compiled without knowing the final memory address of a library function, actually call that function? This is solved with a clever bit of indirection involving two key ELF structures: the Procedure Linkage Table (PLT) and the Global Offset Table (GOT).

When you compile your application to call a function, say library_function(), from a shared library, the compiler doesn’t generate a direct jump to that function’s address. Instead, it generates a call to an entry in the PLT. The PLT is a small trampoline of executable code. The first time the application calls library_function(), the corresponding PLT entry jumps to a special routine within the dynamic linker. The linker then looks up the symbol library_function, finds its real address in the loaded library, and writes that address into the Global Offset Table (GOT). The GOT is essentially a table of addresses. Finally, the PLT entry is patched to jump directly to the address now stored in the GOT.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans'}}}%%
flowchart TD
    subgraph Application Space
        A("App calls library_function()") --> B["PLT Entry for library_function"];
    end

    subgraph Dynamic Linking Mechanism
        B --> C{"GOT entry for<br><i>library_function</i> resolved?"};
        C -->|"No (First Call)"| D[Jump to Dynamic Linker Routine];
        D --> E[Linker finds address of<br><i>library_function</i> in libfoo.so];
        E --> F[Update GOT entry with<br>real function address];
        F --> G[Jump to real function address];
        C -->|"Yes (Subsequent Calls)"| H[Read address from GOT];
        H --> G;
    end
    
    subgraph Shared Library Space
        G --> I["Execute code for library_function()"];
    end

    I --> J(Return to Application);

    %% Styling
    style A fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
    style B fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style C fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff
    style D fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    style E fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    style F fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    style G fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style H fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style I fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style J fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff

This mechanism, called lazy binding, is highly efficient. The cost of resolving a function’s address is only paid once, the very first time it is called. All subsequent calls are nearly as fast as a direct function call, involving just one extra indirect jump. This entire process is transparent to the developer but is fundamental to how shared libraries work under the hood.

The Search for Shared Libraries

The most common point of failure in using shared libraries is when the dynamic linker cannot find a required .so file. The error message is infamous: cannot open shared object file: No such file or directory. This begs the question: where does ld.so look for these files? The search process follows a strict, predefined order.

  1. The rpath and runpath Attributes: A developer can choose to embed a specific search path directly into the executable itself at link time. This is done using the linker flags -rpath and -runpath. The DT_RPATH or DT_RUNPATH attribute is added to the executable’s .dynamic section. The dynamic linker will check these paths first. This provides a powerful way to create self-contained applications that bundle their own libraries in a known, relative location. The key difference between them is subtle but important: rpath is searched before LD_LIBRARY_PATH, while runpath is searched after. Furthermore, the rpath of an executable is also used to find dependencies of its dependencies, whereas runpath is not, providing more control over the search process. For embedded systems, rpath is often preferred for creating relocatable application bundles.
  2. The LD_LIBRARY_PATH Environment Variable: If the library is not found via rpath/runpath, the linker then checks the LD_LIBRARY_PATH environment variable. This is a colon-separated list of directories that the user can set before running the program. For example: LD_LIBRARY_PATH=/opt/my_app/lib:/usr/local/custom/lib ./my_app. This method is incredibly useful for development and testing, as it allows you to override system libraries or test new versions of a library without installing them system-wide. However, its use in a production embedded environment is often discouraged. It can be a security risk if not set carefully, and it can make system behavior dependent on the environment in which a program is launched, leading to reproducibility issues.
  3. The ldconfig Cache: If the previous steps fail, the dynamic linker consults a special cache file, /etc/ld.so.cache. This file contains a compiled, sorted list of libraries found in trusted, standard directories. This cache is maintained by the ldconfig utility. The directories that ldconfig scans are defined in /etc/ld.so.conf and any files included from /etc/ld.so.conf.d/. Standard paths typically include /lib/usr/lib/usr/local/lib, and their architecture-specific variants (e.g., /usr/lib/aarch64-linux-gnu on the Raspberry Pi 5). Using the cache is much faster than manually searching directories on the filesystem for every program launch. This is the standard, “production” way to make libraries available system-wide. When you install a new library into a standard location, you must run ldconfig (usually as root) to update the cache so the dynamic linker can find it.
  4. Default System Directories: As a final fallback, if the cache is missing or the library isn’t listed, the linker will manually search a hardcoded set of default paths, typically /lib and /usr/lib. This is a last resort and is much less efficient than using the cache.

Understanding this search hierarchy is the key to mastering shared library management. For development, LD_LIBRARY_PATH is your flexible friend. For relocatable application packages, rpath is your robust tool. For system-wide deployment in a production environment, installing to a standard path and using ldconfig is the professional standard.

Practical Examples

Theory provides the foundation, but true understanding comes from hands-on practice. In this section, we will walk through the complete process of creating, linking, deploying, and debugging a simple application and its custom shared library on a Raspberry Pi 5.

Prerequisites: This section assumes you have a working Raspberry Pi 5 running a standard 64-bit Raspberry Pi OS (or a similar Debian-based distribution). You should have the build-essential package installed, which provides the GCC compiler, make, and other core development tools (sudo apt-get install build-essential).

Step 1: Creating a Simple Shared Library

First, we’ll create a library that performs a simple mathematical operation. Let’s call our library libcalchelper. By convention, shared library source files are often grouped in their own directory.

%%{init: {'theme': 'base', 'themeVariables': { 'fontFamily': 'Open Sans'}}}%%
graph TD
    subgraph "Build Shared Library"
        direction LR
        A["calchelper.c<br>calchelper.h"] -->|gcc -fPIC -c| B[calchelper.o];
        B -->|gcc -shared| C((libcalchelper.so));
    end

    subgraph "Build Application"
        direction LR
        D["main.c"] -->|gcc -c -I../calchelper| E[main.o];
    end

    subgraph "Final Linking Stage"
        E --> F;
        C --> F((Linker));
        F -->|gcc -o main_app<br>-L../calchelper -lcalchelper| G([main_app Executable]);
    end

    %% Styling
    style A fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style B fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style C fill:#8b5cf6,stroke:#8b5cf6,stroke-width:2px,color:#ffffff
    style D fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style E fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    style F fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff
    style G fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff

File Structure:

Plaintext
calchelper/
├── calchelper.c
├── calchelper.h
└── Makefile

mcalchelper.h – The Header File

This file defines the public interface of our library. Any application that wants to use our library will include this header.

C
#ifndef CALCHELPER_H
#define CALCHELPER_H

/*
 * calchelper.h
 *
 * Public API for the Calculator Helper library.
 * This library provides simple arithmetic functions.
 */

/**
 * @brief Adds two integers.
 *
 * @param a The first integer.
 * @param b The second integer.
 * @return The sum of a and b.
 */
int add(int a, int b);

/**
 * @brief Subtracts the second integer from the first.
 *
 * @param a The first integer.
 * @param b The second integer.
 * @return The result of a - b.
 */
int subtract(int a, int b);

#endif // CALCHELPER_H

calchelper.c – The Implementation

This is the source code that implements the functions declared in the header.

C
#include "calchelper.h"

/*
 * calchelper.c
 *
 * Implementation of the Calculator Helper library functions.
 */

int add(int a, int b) {
    return a + b;
}

int subtract(int a, int b) {
    return a - b;
}

Makefile – The Build Script

Now, we need to compile this source code into a shared object (.so) file. This requires two special GCC flags:

  • -fPIC: This stands for Position-Independent Code. Because a shared library can be loaded at any address in memory, its code cannot rely on absolute memory addresses. This flag tells the compiler to generate code that uses relative addressing, making it suitable for a shared library. This is mandatory for shared libraries on most architectures, including ARM64.
  • -shared: This flag tells the linker to produce a shared library file rather than a standard executable.
Makefile
# Makefile for building the libcalchelper shared library

# Compiler and flags
CC = gcc
CFLAGS = -Wall -Werror -fPIC
LDFLAGS = -shared
TARGET = libcalchelper.so

# Source files
SRCS = calchelper.c
OBJS = $(SRCS:.c=.o)

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(LDFLAGS) -o $(TARGET) $(OBJS)
	@echo "Shared library $(TARGET) created successfully."

%.o: %.c calchelper.h
	$(CC) $(CFLAGS) -c $< -o $@

clean:
	rm -f $(OBJS) $(TARGET)
	@echo "Cleanup complete."

Build the Library:

From within the calchelper/ directory, simply run make.

Bash
pi@raspberrypi:~/calchelper $ make
gcc -Wall -Werror -fPIC -c calchelper.c -o calchelper.o
gcc -shared -o libcalchelper.so calchelper.o
Shared library libcalchelper.so created successfully.
pi@raspberrypi:~/calchelper $ ls
calchelper.c  calchelper.h  calchelper.o  libcalchelper.so  Makefile

You now have libcalchelper.so, your first shared library!

Step 2: Creating and Linking an Application

Next, we’ll create a simple command-line application that uses our new library.

File Structure:

Plaintext
main_app/
├── main.c
└── Makefile

main.c – The Application Code

This program will include calchelper.h and call its functions.

C
#include <stdio.h>
#include <stdlib.h>
#include "../calchelper/calchelper.h" // Include the library header

int main(int argc, char *argv[]) {
    if (argc != 3) {
        fprintf(stderr, "Usage: %s <int1> <int2>\n", argv[0]);
        return 1;
    }

    int num1 = atoi(argv[1]);
    int num2 = atoi(argv[2]);

    int sum = add(num1, num2);
    int diff = subtract(num1, num2);

    printf("Calculator App\n");
    printf("==============\n");
    printf("Sum of %d and %d is: %d\n", num1, num2, sum);
    printf("Difference of %d and %d is: %d\n", num1, num2, diff);

    return 0;
}

Makefile – The Application Build Script

To link this application against our shared library, we need to tell the linker two things: the name of the library and where to find it.

  • -L<path>: This flag tells the linker an additional directory to search for libraries. We’ll point it to our calchelper directory.
  • -l<name>: This flag tells the linker the name of the library to link. The convention is to omit the lib prefix and the .so suffix. So, to link against libcalchelper.so, we use -lcalchelper.
Makefile
# Makefile for building the main application

# Compiler and flags
CC = gcc
CFLAGS = -Wall -I../calchelper # -I adds a directory to the header search path
LDFLAGS = -L../calchelper -lcalchelper
TARGET = main_app

# Source files
SRCS = main.c
OBJS = $(SRCS:.c=.o)

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) -o $(TARGET) $(OBJS) $(LDFLAGS)
	@echo "Application $(TARGET) created successfully."

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

clean:
	rm -f $(OBJS) $(TARGET)
	@echo "Cleanup complete."

Build the Application:

From within the main_app/ directory, run make.

Bash
pi@raspberrypi:~/main_app $ make
gcc -Wall -I../calchelper -c main.c -o main.o
gcc -o main_app main.o -L../calchelper -lcalchelper
Application main_app created successfully.
pi@raspberrypi:~/main_app $ ls
main_app  main.c  main.o  Makefile

Step 3: Deployment and Troubleshooting

We now have our application main_app and our library libcalchelper.so. Let’s try to run it.

Bash
pi@raspberrypi:~/main_app $ ./main_app 10 5
./main_app: error while loading shared libraries: libcalchelper.so: cannot open shared object file: No such file or directory

This is the classic error. The linker found the library at compile time because we used the -L flag. But at runtime, the dynamic linker (ld.so) has no idea where libcalchelper.so is. It only searches the standard paths.

We can verify this dependency using the ldd (List Dynamic Dependencies) utility.

Bash
pi@raspberrypi:~/main_app $ ldd ./main_app
	linux-vdso.so.1 (0x0000ffff9b7e9000)
	libcalchelper.so => not found
	libc.so.6 (0x0000ffff9b40a000)
	/lib/ld-linux-aarch64.so.1 (0x0000ffff9b7b5000)

ldd confirms that the system cannot locate libcalchelper.so. Now, let’s fix this using the three methods we discussed.

Solution A: Using LD_LIBRARY_PATH (Development)

We can tell the dynamic linker where to look by setting the LD_LIBRARY_PATH environment variable to point to the directory containing our library.

Bash
pi@raspberrypi:~/main_app $ export LD_LIBRARY_PATH=../calchelper/
pi@raspberrypi:~/main_app $ ./main_app 10 5
Calculator App
==============
Sum of 10 and 5 is: 15
Difference of 10 and 5 is: 5
pi@raspberrypi:~/main_app $ ldd ./main_app
	linux-vdso.so.1 (0x0000ffff87de9000)
	libcalchelper.so => ../calchelper/libcalchelper.so (0x0000ffff87d9c000)
	libc.so.6 (0x0000ffff87a0a000)
	/lib/ld-linux-aarch64.so.1 (0x0000ffff87db5000)

Success! ldd now shows the resolved path. This method is perfect for quick tests during development.

Tip: You can set the variable for a single command without exporting it globally: LD_LIBRARY_PATH=../calchelper/ ./main_app 10 5

Solution B: Using rpath (Relocatable Application)

What if we want our application to find its library without setting environment variables? We can embed the path using rpath. We need to modify the application’s Makefile to pass a new flag to the linker.

The flag is -Wl,-rpath,PATH. The -Wl, part passes the option that follows it directly to the linker. We can use the special $ORIGIN variable in the rpath, which tells the linker to look in a path relative to the executable’s own location. This is extremely useful for creating relocatable bundles.

Let’s modify main_app/Makefile and rebuild. We’ll assume we will deploy the library in a lib subdirectory next to the executable.

Modified main_app/Makefile:

Bash
# ... (previous content) ...
# Note the new rpath setting. We point to a 'lib' directory relative to the executable.
LDFLAGS = -L../calchelper -lcalchelper -Wl,-rpath,'$ORIGIN/../lib'
TARGET = main_app
# ... (rest of the file) ...

Now, let’s create a deployment structure and test it.

Bash
# Clean and rebuild the app with the new rpath
pi@raspberrypi:~/main_app $ make clean && make

# Create a deployment directory
pi@raspberrypi:~$ mkdir deploy
pi@raspberrypi:~$ 


# Copy the files into the deployment structure
pi@raspberrypi:~$ cp main_app/main_app deploy/
pi@raspberrypi:~$ cp calchelper/libcalchelper.so deploy/lib/

# Navigate to the deployment directory and run
pi@raspberrypi:~$ cd deploy/
pi@raspberrypi:~/deploy $ unset LD_LIBRARY_PATH # Make sure the old variable is gone
pi@raspberrypi:~/deploy $ ./main_app 20 8
Calculator App
==============
Sum of 20 and 8 is: 28
Difference of 20 and 8 is: 20

It works! The application now inherently knows where to find its library. We can verify the embedded rpath using the readelf utility.

Bash
pi@raspberrypi:~/deploy $ readelf -d ./main_app | grep 'rpath'
 0x000000000000001d (RPATH)              Library rpath: [$ORIGIN/../lib]

Solution C: Using ldconfig (Production System-Wide Installation)

The most common method for production systems is to install the library into a standard system directory and update the dynamic linker’s cache.

Warning: This method requires root privileges as you are modifying the system’s library directories.

First, let’s rebuild the application without the rpath so it relies on the standard search mechanism. (Revert the LDFLAGS in main_app/Makefile and make again).

Now, let’s copy our library and application to standard locations.

  • Libraries often go in /usr/local/lib.
  • Executables often go in /usr/local/bin.
Bash
# Copy the library to a standard system path
pi@raspberrypi:~$ sudo cp calchelper/libcalchelper.so /usr/local/lib/

# Copy the application to a standard executable path
pi@raspberrypi:~$ sudo cp main_app/main_app /usr/local/bin/

# Try to run it from anywhere
pi@raspberrypi:~$ main_app 100 50
main_app: error while loading shared libraries: libcalchelper.so: cannot open shared object file: No such file or directory

It fails again! Why? Because /usr/local/lib is a standard directory, but the dynamic linker’s cache (/etc/ld.so.cache) doesn’t know about our new file yet. We must update it.

Bash
# Update the dynamic linker cache
pi@raspberrypi:~$ sudo ldconfig

# Now, try again
pi@raspberrypi:~$ main_app 100 50
Calculator App
==============
Sum of 100 and 50 is: 150
Difference of 100 and 50 is: 50

Success! The system now knows about libcalchelper.so and any application can link against it without special environment variables or rpath settings. We can verify its presence in the cache.

Bash
pi@raspberrypi:~$ ldconfig -p | grep calchelper
	libcalchelper.so (libc6,AArch64) => /usr/local/lib/libcalchelper.so

This confirms the library is now part of the system’s known shared libraries.

Common Mistakes & Troubleshooting

Navigating the complexities of dynamic linking can be tricky, and several common pitfalls can trip up even experienced developers. Understanding these issues is the first step to quickly resolving them.

Mistake / Issue Symptom(s) Troubleshooting / Solution
Library Not Found at Runtime The classic error message:
cannot open shared object file: No such file or directory
1. Use ldd ./your_app to confirm which library is missing.
2. For Development: Check LD_LIBRARY_PATH. Is it set and exported correctly? (echo $LD_LIBRARY_PATH).
3. For Relocatable Apps: Check the embedded rpath with readelf -d ./your_app | grep RPATH.
4. For System Install: Did you run sudo ldconfig after copying the library to a standard path?
Architecture Mismatch Error messages like:
wrong ELF class: ELFCLASS32 on a 64-bit system (or vice-versa).
Use the file command on both the application and the library:
file ./your_app
file /path/to/libfoo.so
Ensure both are compiled for the same architecture (e.g., ARM aarch64). Recompile the incorrect component with the proper cross-compiler.
Abusing LD_LIBRARY_PATH Application runs fine in your shell but fails when started as a systemd service or by another user. Don’t use LD_LIBRARY_PATH for production deployment.
1. Preferred: Re-link the application using rpath to create a self-contained package.
2. Alternative: Install the library to a standard system directory (e.g., /usr/local/lib) and run sudo ldconfig.
Undefined Symbol at Runtime Application launches but then crashes with an undefined symbol: xyz error, even though ldd finds the library. This means the library being loaded is an older version that doesn’t contain the required function.
1. Verify the correct library version is installed on the target.
2. Use readelf -s /path/to/lib.so | grep function_name to confirm the symbol exists in the version being loaded by the system.

Exercises

These exercises are designed to reinforce the concepts of this chapter. They should be performed on your Raspberry Pi 5.

  1. Library Expansion:
    • Objective: Add new functionality to the libcalchelper library and use it in the main application.
    • Steps:
      1. Add declarations for multiply(int a, int b) and divide(int a, int b) to calchelper.h.
      2. Implement these functions in calchelper.c. The divide function should handle division by zero gracefully by printing an error and returning 0.
      3. Rebuild the libcalchelper.so library using its Makefile.
      4. Modify main.c to accept an operator (+-*/) as a command-line argument and call the appropriate library function.
      5. Rebuild and redeploy the application and library using the ldconfig method.
    • Verification: Run your application with different numbers and all four operators to ensure it works correctly. Test the division-by-zero case.
  2. The ldd Detective:
    • Objective: Use ldd to explore the dependencies of common system utilities.
    • Steps:
      1. Run ldd on several executables in /bin and /usr/bin. For example: ldd /bin/lsldd /bin/bashldd /usr/bin/ssh.
      2. Identify the common libraries that most applications depend on (e.g., libc.so.6ld-linux-aarch64.so.1).
      3. For one of the libraries you found (e.g., libpcre2-8.so.0 from grep), use ldconfig -p | grep <library_name> to see its path as known by the cache.
    • Verification: Note your findings. Do you see a pattern in the dependencies? This exercise helps build a mental map of the standard Linux runtime environment.
  3. rpath vs. runpath:
    • Objective: Observe the difference in search order between rpath and runpath.
    • Steps:
      1. Create a second version of libcalchelper.so (e.g., libcalchelper_v2.so) in a different directory (e.g., ~/calchelper_v2). In this version, change the add function to return a + b + 100.
      2. Compile main_app using -Wl,-rpath,PATH_TO_V1_LIB.
      3. Set LD_LIBRARY_PATH to point to the directory of libcalchelper_v2.so.
      4. Run the app. Which version of the add function is called? (It should be V1, because rpath is checked before LD_LIBRARY_PATH).
      5. Now, recompile main_app using -Wl,-runpath,PATH_TO_V1_LIB.
      6. Repeat step 3 and 4. Which version is called now? (It should be V2, because LD_LIBRARY_PATH is checked before runpath).
    • Verification: The output of the program will clearly show which library was loaded based on the sum it prints.
  4. Inspecting Symbols with readelf:
    • Objective: Learn to inspect the symbols within library and executable files.
    • Steps:
      1. Run readelf -s libcalchelper.so. Find the entries for your add and subtract functions. Note their Type (should be FUNC) and Bind (should be GLOBAL).
      2. Run readelf -s main_app. Find the entries for add and subtract. Note their Bind (should be GLOBAL) and that their Ndx (Section Index) is UND (Undefined), because their actual code resides elsewhere.
      3. Look for the GOT and PLT entries related to these functions.
    • Verification: This exercise provides direct insight into the ELF structures that enable dynamic linking.
  5. Cross-Compilation and Deployment:
    • Objective: Simulate a real-world embedded workflow by cross-compiling and deploying the application.
    • Steps:
      1. On your x86_64 host machine, install the ARM64 cross-compiler (sudo apt-get install gcc-aarch64-linux-gnu).
      2. Modify both Makefiles to use aarch64-linux-gnu-gcc as the CC.
      3. Compile both the library and the application on your host machine.
      4. Use scp or another method to copy the compiled main_app and libcalchelper.so to your Raspberry Pi 5.
      5. On the Pi, place the files in a directory and use the LD_LIBRARY_PATH method to run the application.
    • Verification: The cross-compiled application should run correctly on the Raspberry Pi 5 target. Use the file command on both the host and target to confirm the architecture of the binaries.

Summary

This chapter provided a comprehensive exploration of shared libraries, a cornerstone of modern embedded Linux development. By mastering these concepts, you can create more efficient, modular, and maintainable systems.

  • Static vs. Dynamic Linking: We contrasted the simplicity of static linking with the resource efficiency and maintainability of dynamic linking, which is the preferred method for most complex embedded systems.
  • The Linking Process: We demystified the runtime linking process, including the role of the ELF format, the dynamic linker (ld.so), and the clever indirection provided by the Procedure Linkage Table (PLT) and Global Offset Table (GOT).
  • Library Search Paths: You learned the three primary mechanisms for controlling how the dynamic linker finds libraries: the LD_LIBRARY_PATH environment variable for development, the rpath/runpath embedded attributes for relocatable applications, and the ldconfig cache for system-wide production deployment.
  • Practical Tooling: We gained hands-on experience with essential command-line utilities. We used gcc with -fPIC and -shared flags to build libraries, ldd to diagnose dependencies, readelf to inspect ELF internals, and ldconfig to manage the system library cache.
  • Problem Solving: By understanding the search path hierarchy and common pitfalls, you are now equipped to diagnose and solve the ubiquitous “cannot open shared object file” error and other related linking issues.

The ability to effectively manage shared libraries is not just a technical skill; it is fundamental to sound system architecture in the embedded world. The principles learned here will be applied repeatedly as you build more sophisticated applications and integrate third-party software into your projects.

Further Reading

  1. ld.so(8) Linux Manual Page: The authoritative reference for the dynamic linker. Access it on your system with man 8 ld.so. It details the search path order and all relevant environment variables.
  2. GCC Linker Options Documentation: The official documentation for linker options (-l-L-rpath, etc.) is essential reading. https://gcc.gnu.org/onlinedocs/gcc/Link-Options.html
  3. System V Application Binary Interface (ABI): For a truly deep dive, the System V ABI specification (architecture-specific supplements exist) defines the ELF format, dynamic linking, and the roles of the PLT and GOT. https://refspecs.linuxfoundation.org/elf/gabi4+/contents.html
  4. Ulrich Drepper’s “How To Write Shared Libraries”: A detailed, classic paper that, while slightly dated in parts, provides an exceptional explanation of the concepts, motivations, and low-level details of creating shared libraries. https://www.akkadia.org/drepper/dsohowto.pdf
  5. The readelf and ldd man pages: man readelf and man ldd. The documentation for these tools is the best place to learn about all their powerful options for inspecting and debugging binaries.
  6. Raspberry Pi Documentation – The Linux kernel: While not specific to libraries, understanding the underlying OS is crucial. https://www.raspberrypi.com/documentation/computers/linux_kernel.html

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top