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
, therpath
mechanism, and standard system directories. - Utilize core system utilities like
ldd
,ldconfig
, andreadelf
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.
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.
- The
rpath
andrunpath
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
. TheDT_RPATH
orDT_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 beforeLD_LIBRARY_PATH
, whilerunpath
is searched after. Furthermore, therpath
of an executable is also used to find dependencies of its dependencies, whereasrunpath
is not, providing more control over the search process. For embedded systems,rpath
is often preferred for creating relocatable application bundles. - The
LD_LIBRARY_PATH
Environment Variable: If the library is not found viarpath
/runpath
, the linker then checks theLD_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. - 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 theldconfig
utility. The directories thatldconfig
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 runldconfig
(usually as root) to update the cache so the dynamic linker can find it. - 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:
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.
#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.
#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 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.
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:
main_app/
├── main.c
└── Makefile
main.c – The Application Code
This program will include calchelper.h and call its functions.
#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 ourcalchelper
directory.-l<name>
: This flag tells the linker the name of the library to link. The convention is to omit thelib
prefix and the.so
suffix. So, to link againstlibcalchelper.so
, we use-lcalchelper
.
# 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.
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.
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.
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.
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
:
# ... (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.
# 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.
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
.
# 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.
# 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.
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.
Exercises
These exercises are designed to reinforce the concepts of this chapter. They should be performed on your Raspberry Pi 5.
- Library Expansion:
- Objective: Add new functionality to the
libcalchelper
library and use it in the main application. - Steps:
- Add declarations for
multiply(int a, int b)
anddivide(int a, int b)
tocalchelper.h
. - Implement these functions in
calchelper.c
. Thedivide
function should handle division by zero gracefully by printing an error and returning 0. - Rebuild the
libcalchelper.so
library using itsMakefile
. - Modify
main.c
to accept an operator (+
,-
,*
,/
) as a command-line argument and call the appropriate library function. - Rebuild and redeploy the application and library using the
ldconfig
method.
- Add declarations for
- Verification: Run your application with different numbers and all four operators to ensure it works correctly. Test the division-by-zero case.
- Objective: Add new functionality to the
- The
ldd
Detective:- Objective: Use
ldd
to explore the dependencies of common system utilities. - Steps:
- Run
ldd
on several executables in/bin
and/usr/bin
. For example:ldd /bin/ls
,ldd /bin/bash
,ldd /usr/bin/ssh
. - Identify the common libraries that most applications depend on (e.g.,
libc.so.6
,ld-linux-aarch64.so.1
). - For one of the libraries you found (e.g.,
libpcre2-8.so.0
fromgrep
), useldconfig -p | grep <library_name>
to see its path as known by the cache.
- Run
- 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.
- Objective: Use
rpath
vs.runpath
:- Objective: Observe the difference in search order between
rpath
andrunpath
. - Steps:
- Create a second version of
libcalchelper.so
(e.g.,libcalchelper_v2.so
) in a different directory (e.g.,~/calchelper_v2
). In this version, change theadd
function to returna + b + 100
. - Compile
main_app
using-Wl,-rpath,PATH_TO_V1_LIB
. - Set
LD_LIBRARY_PATH
to point to the directory oflibcalchelper_v2.so
. - Run the app. Which version of the
add
function is called? (It should be V1, becauserpath
is checked beforeLD_LIBRARY_PATH
). - Now, recompile
main_app
using-Wl,-runpath,PATH_TO_V1_LIB
. - Repeat step 3 and 4. Which version is called now? (It should be V2, because
LD_LIBRARY_PATH
is checked beforerunpath
).
- Create a second version of
- Verification: The output of the program will clearly show which library was loaded based on the sum it prints.
- Objective: Observe the difference in search order between
- Inspecting Symbols with
readelf
:- Objective: Learn to inspect the symbols within library and executable files.
- Steps:
- Run
readelf -s libcalchelper.so
. Find the entries for youradd
andsubtract
functions. Note theirType
(should beFUNC
) andBind
(should beGLOBAL
). - Run
readelf -s main_app
. Find the entries foradd
andsubtract
. Note theirBind
(should beGLOBAL
) and that theirNdx
(Section Index) isUND
(Undefined), because their actual code resides elsewhere. - Look for the GOT and PLT entries related to these functions.
- Run
- Verification: This exercise provides direct insight into the ELF structures that enable dynamic linking.
- Cross-Compilation and Deployment:
- Objective: Simulate a real-world embedded workflow by cross-compiling and deploying the application.
- Steps:
- On your x86_64 host machine, install the ARM64 cross-compiler (
sudo apt-get install gcc-aarch64-linux-gnu
). - Modify both
Makefiles
to useaarch64-linux-gnu-gcc
as theCC
. - Compile both the library and the application on your host machine.
- Use
scp
or another method to copy the compiledmain_app
andlibcalchelper.so
to your Raspberry Pi 5. - On the Pi, place the files in a directory and use the
LD_LIBRARY_PATH
method to run the application.
- On your x86_64 host machine, install the ARM64 cross-compiler (
- 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, therpath
/runpath
embedded attributes for relocatable applications, and theldconfig
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, andldconfig
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
ld.so(8)
Linux Manual Page: The authoritative reference for the dynamic linker. Access it on your system withman 8 ld.so
. It details the search path order and all relevant environment variables.- 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 - 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
- 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
- The
readelf
andldd
man pages:man readelf
andman ldd
. The documentation for these tools is the best place to learn about all their powerful options for inspecting and debugging binaries. - 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