Chapter 89: Shared Libraries (.so): Creation Process (PIC, -shared
)
Chapter Objectives
By the end of this chapter, you will be able to:
- Understand the fundamental differences between static and dynamic linking and their respective impacts on memory usage and application maintenance.
- Explain the concept of Position-Independent Code (PIC) and its critical role in enabling shared libraries.
- Implement the complete workflow for creating a shared library (
.so
file) from C source code using the GCC toolchain. - Configure an embedded Linux system to correctly locate and use shared libraries at runtime using
ldconfig
and theLD_LIBRARY_PATH
environment variable. - Debug common issues related to shared library creation and usage, such as unresolved symbols and linking errors.
- Analyze the structure of shared libraries using tools like
readelf
to inspect their internal components, such as the Global Offset Table (GOT).
Introduction
In the world of embedded systems, efficiency is paramount. Every byte of RAM and every CPU cycle is a precious resource. As embedded applications grow in complexity, managing this efficiency becomes a significant challenge. Imagine an embedded device, perhaps a smart home hub or an industrial controller, running a dozen different applications. If each application includes its own copy of common functionalities—like a math library, a string manipulation utility, or a communication protocol stack—the result is a tremendous waste of memory. This is where the power of dynamic linking and shared libraries becomes evident.
A shared library, known on Linux systems by its .so
(Shared Object) extension, is a collection of compiled code designed to be shared by multiple programs simultaneously. Instead of statically linking the same code into every application that needs it, a single copy of the shared library is loaded into memory by the operating system. Each application can then access this shared code, drastically reducing the overall memory footprint of the system. This approach not only saves space but also simplifies software maintenance. To update a shared function, you only need to replace the shared library file; all applications using it will benefit from the update automatically, without needing to be recompiled themselves.
This chapter delves into the core principles and practical mechanics of creating and using shared libraries in an embedded Linux environment, using the Raspberry Pi 5 as our development platform. We will explore the crucial concept of Position-Independent Code (PIC), the cornerstone that allows a single library to be loaded at different memory addresses for different applications. You will learn the specific GCC compiler and linker flags—-fPIC
and -shared
—that transform your source code into a functional shared library. By the end of this chapter, you will have the practical skills to build, deploy, and manage shared libraries, a fundamental technique for creating modular, efficient, and maintainable embedded Linux systems.
Technical Background
The Tale of Two Linkers: Static vs. Dynamic Linking
Before we can appreciate the elegance of shared libraries, we must first understand the process that makes them possible: linking. After the compiler translates your human-readable source code into machine-readable object files (.o
files), the linker’s job is to assemble these object files, along with any required library code, into a final executable program. This process can happen in one of two ways: statically or dynamically.
Static linking is the more straightforward approach. The linker acts like a meticulous archivist, finding every piece of code a program needs—from your own object files and from any static libraries (.a
files)—and copying it directly into the final executable file. The result is a single, self-contained binary. This has the advantage of simplicity and portability; the executable has no external dependencies and will run on any compatible system without needing to find specific library files. However, this simplicity comes at a cost. As mentioned earlier, if multiple programs on a system use the same static library, each will have its own redundant copy embedded within it, consuming significant disk space and RAM. Furthermore, updating a function in a static library requires recompiling and relinking every single application that uses it—a maintenance nightmare for complex systems.
Dynamic linking, in contrast, is a more sophisticated and efficient process. Instead of copying library code into the executable, the linker places a small stub or placeholder in the binary. This stub essentially says, “At runtime, I will need function X
from shared library Y.so
.” When you run the program, a special part of the operating system called the dynamic linker (or loader, typically /lib/ld-linux.so.3
on modern systems) springs into action. It reads the placeholders in the executable, finds the required shared libraries (.so
files) on the system, and loads them into memory. The dynamic linker then resolves the placeholders, patching the program’s memory so that calls to library functions are correctly redirected to the shared code. If another program that needs the same library is started, the dynamic linker is smart enough to see that the library is already in memory and will simply map it into the new program’s address space, rather than loading a second copy. This “load-once, share-many” model is the key to the memory efficiency of dynamic linking.
The Challenge of Sharing: Position-Independent Code (PIC)
The “share-many” aspect of dynamic linking introduces a complex problem. The operating system provides each process with its own private, virtual address space. For security and flexibility, the exact memory address where a shared library will be loaded can change every time a program runs. This is a feature known as Address Space Layout Randomization (ASLR). If one program loads libc.so
at memory address 0xb7400000
and another loads it at 0xb7800000
, how can the same library code work correctly in both places?
If the library’s code contained absolute memory addresses—for example, a jump instruction like JMP 0x12345678
—it would fail catastrophically. The address 0x12345678
might be valid within the context of the first program, but it would point to garbage or protected memory in the second. The library code must be written in a way that it doesn’t depend on being loaded at any specific, fixed address. This is the principle of Position-Independent Code (PIC).
PIC is generated by the compiler when you use the -fPIC
flag. It solves the problem of absolute addressing by using relative addressing instead. Instead of saying “jump to address X
,” a PIC instruction says “jump to the address Y
bytes forward from my current location.” Since the relative distance between different parts of the library code is always the same, these jumps work regardless of where the library is loaded in memory.
However, this only solves part of the problem. What about accessing global variables or calling functions that are also inside the library? The code still needs a way to find their addresses. This is where the magic of the Global Offset Table (GOT) and the Procedure Linkage Table (PLT) comes in.
The Global Offset Table (GOT) and Procedure Linkage Table (PLT)
To achieve true position independence, the linker separates the code (the .text
section), which is immutable and shared, from the data (the .data
section), which can be modified and is private to each process. It then creates two special data sections to act as intermediaries for all external memory accesses: the Global Offset Table (GOT) and the Procedure Linkage Table (PLT).
Think of the Global Offset Table (GOT) as an address book for the shared library. It’s a table of memory addresses located in the library’s private data section. When the library code needs to access a global variable, instead of trying to use a hardcoded, absolute address, it does the following:
- It calculates its own current position in memory.
- It uses this position to find the start of the GOT (which is at a fixed, relative offset).
- It looks up the correct entry in the GOT to get the variable’s actual memory address.
- It then uses that address to access the variable.
This process ensures that even though the variable’s absolute address changes with each program execution, the library code can always find it through the indirection provided by the GOT. The dynamic linker is responsible for filling in the correct addresses in the GOT when it first loads the library into memory.
The Procedure Linkage Table (PLT) provides a similar mechanism of indirection, but specifically for function calls. Calling functions is slightly more complex because we want to avoid the overhead of looking up the function’s address every single time it’s called. The PLT enables a technique called lazy binding.
Here’s how it works:
- When your code calls a function in a shared library for the very first time, it doesn’t actually jump to the function. Instead, it jumps to an entry in the PLT.
- This PLT entry contains a special piece of code that, in turn, jumps to a helper routine inside the dynamic linker itself.
- The dynamic linker’s routine looks up the real address of the requested function (e.g.,
printf
). - Crucially, it then patches the corresponding entry in the GOT with this real address.
- Finally, it jumps to the real function.
Now, the next time your code calls the same function, the process is much faster. The call again goes to the PLT entry, but this time, the PLT entry simply jumps to the address now stored in the GOT. The expensive lookup process by the dynamic linker is skipped. This lazy resolution means that the overhead of finding functions is only paid for the functions that are actually used, improving application startup time.
flowchart TD subgraph Application A["<b>Start:</b><br>Application calls<br>a library function e.g., <i>printf()</i>"] end subgraph "Indirection Layers" B["Process Jumps to<br>Procedure Linkage Table<br>(PLT) entry for <i>printf</i>"] C{"<br>Is address of <i>printf</i><br>in the Global Offset Table<br>(GOT) resolved?"}; end subgraph "Dynamic Linker (ld.so) - First Call Only" D[PLT jumps to helper<br>routine in the<br>Dynamic Linker] E[Linker searches libraries<br>to find the real address<br>of <i>printf</i>] F[<b>Patch GOT:</b><br>Linker writes the real<br>address into the GOT entry<br>for <i>printf</i>] end subgraph "Shared Library Code" G[<b>Execute:</b><br>Jump to the real<br><i>printf</i> function in memory] end A --> B; B --> C; C -- No --> D; D --> E; E --> F; F --> G; C -- Yes (Subsequent Calls) --> G; %% Styling style A fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff style G fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff style C fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff style B fill:#0d9488,stroke:#0d9488,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:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff
The ELF Format: A Blueprint for Executables and Libraries
The structure that orchestrates this complex dance of linking and loading is the Executable and Linkable Format (ELF). ELF is the standard binary file format used by most modern Unix-like operating systems, including Linux. It defines the structure for executables, object files, and, most importantly for us, shared libraries.
An ELF file is composed of a few key components:
- ELF Header: This is at the very beginning of the file and acts as a roadmap. It contains essential information like the file type (e.g., executable or shared object), the target machine architecture (e.g., AArch64 for the Raspberry Pi 5), and pointers to the other major components of the file.
- Program Header Table: This table tells the system how to create a process image in memory. For an executable, it describes which segments of the file (like the code and data sections) need to be loaded into memory and what their permissions should be (e.g., read-only and executable for code, read-write for data).
- Section Header Table: This table provides a more detailed view of the file’s contents, breaking it down into “sections.” Sections hold the bulk of the object file information: compiled code (
.text
), initialized data (.data
), uninitialized data (.bss
), the GOT, the PLT, symbol tables (.symtab
), and relocation information. When the linker combines object files, it merges sections of the same type. - Sections: These are the actual chunks of code and data. For a shared library, the most relevant sections are
.text
(the PIC code),.got
, and.plt
, as well as the dynamic linking information found in sections like.dynamic
and.dynsym
. The.dynamic
section contains entries that the dynamic linker uses, such as a list of required libraries and the location of the GOT.
When you use the gcc -shared
command, you are instructing the linker to produce an ELF file of type DYN
(shared object). This file will contain the necessary sections and program headers to allow the dynamic linker to load it into memory, perform relocations (i.e., fill in the GOT), and map it into a process’s address space correctly. Understanding the ELF structure is key to troubleshooting linking problems, as tools like readelf
and objdump
allow you to peer inside these files and see exactly how they are constructed.
Practical Examples
Now that we have a solid theoretical foundation, let’s put it into practice. In this section, we will create a simple shared library for a custom logging utility, compile an application that uses it, and run it on our Raspberry Pi 5.
graph TD subgraph "1- Library Creation" A(Write Library Code<br><i>log.c, log.h</i>) B(Compile to Object File<br><b>gcc -c -fPIC log.c -o log.o</b>) C(Link into Shared Library<br><b>gcc -shared log.o -o libmylogger.so</b>) end subgraph "2- Application Creation" D(Write Application Code<br><i>main.c</i>) E(Compile and Link Application<br><b>gcc main.c -L. -lmylogger -o my_app</b>) end subgraph "3- Execution" F{Run Application<br><b>./my_app</b>} G{Dynamic Linker<br>Finds <i>libmylogger.so</i>?} H[<b>Success!</b><br>Program runs correctly] I["<b>Error!</b><br>"cannot open shared<br>object file""] end subgraph "4- Solution for Runtime" J(Use <b>LD_LIBRARY_PATH</b><br>for development) K(Install to system dir<br>& run <b>ldconfig</b> for production) end A --> B --> C D --> E C --> E E --> F F --> G G -- Yes --> H G -- No --> I I --> J I --> K %% Styling style A fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff style D fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff style H fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff style I fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff style G fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff style B fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff style C fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff style E fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff style F fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff style J fill:#eab308,stroke:#eab308,stroke-width:1px,color:#1f2937 style K fill:#eab308,stroke:#eab308,stroke-width:1px,color:#1f2937
Step 1: Setting Up the Project Environment
First, let’s create a directory for our project and organize our files. A clean structure makes managing larger projects much easier.
# On your Raspberry Pi 5 or cross-compilation host
mkdir shared_lib_project
cd shared_lib_project
mkdir mylogger
mkdir app
Our project will have two main parts:
mylogger/
: This directory will contain the source code for our shared logging library.app/
: This directory will contain the source code for a simple test application that uses our library.
Step 2: Creating the Shared Library Source Code
We will create a simple logger that provides functions to log messages at different levels (INFO, WARNING, ERROR).
Create a header file mylogger/log.h
:
// File: mylogger/log.h
#ifndef LOG_H
#define LOG_H
/*
* @brief Logs an informational message.
* @param msg The message string to log.
*/
void log_info(const char *msg);
/*
* @brief Logs a warning message.
* @param msg The message string to log.
*/
void log_warning(const char *msg);
/*
* @brief Logs an error message.
* @param msg The message string to log.
*/
void log_error(const char *msg);
#endif // LOG_H
Now, create the implementation file mylogger/log.c
:
// File: mylogger/log.c
#include <stdio.h>
#include "log.h"
void log_info(const char *msg) {
printf("[INFO] %s\n", msg);
}
void log_warning(const char *msg) {
// In a real application, this might write to a different file or use color
fprintf(stdout, "[WARNING] %s\n", msg);
}
void log_error(const char *msg) {
// Errors are typically written to standard error
fprintf(stderr, "[ERROR] %s\n", msg);
}
This is a very basic library, but it’s perfect for demonstrating the compilation and linking process.
Step 3: Compiling the Shared Library
This is the most critical step. We need to compile log.c
into a shared object file, libmylogger.so
. This involves two key GCC flags:
-fPIC
: This tells the compiler to generate Position-Independent Code. As we discussed, this is essential for any code that will be part of a shared library.-shared
: This tells the linker to create a shared library (.so
file) instead of a standard executable.
Let’s execute the command. Navigate to the mylogger
directory first.
cd mylogger
# Compile log.c into an object file with Position-Independent Code
# -c: Compile and assemble, but do not link.
# -fPIC: Generate position-independent code.
# -o log.o: Specify the output object file name.
gcc -c -fPIC -o log.o log.c
# Link the object file into a shared library
# -shared: Produce a shared object which can then be linked with other objects to form an executable.
# -o libmylogger.so: The output file name. By convention, shared libraries are named lib<name>.so
gcc -shared -o libmylogger.so log.o
# Let's see what we've created
ls -l
You should see the following files in your mylogger
directory:
log.c
: The original source code.log.h
: The header file.log.o
: The position-independent object file.libmylogger.so
: Our brand new shared library!
Tip: The
lib
prefix inlibmylogger.so
is a standard naming convention on Linux. When you later link against this library using the-l
flag (e.g.,-lmylogger
), the linker will automatically search for a file namedlibmylogger.so
.
Step 4: Creating the Test Application
Now we need a program that actually uses our library. Go back to the project root and into the app
directory.
cd ../app
Create a file named main.c
:
// File: app/main.c
#include "../mylogger/log.h" // Include the library header
int main() {
log_info("Application starting up.");
log_warning("Configuration file not found, using defaults.");
// Simulate some work
for (int i = 0; i < 3; ++i) {
log_info("Doing work...");
}
log_error("Failed to connect to the sensor device!");
log_info("Application shutting down.");
return 0;
}
This simple program includes our log.h
header and calls the functions we defined.
Step 5: Compiling and Linking the Application
To compile main.c
, we need to tell the compiler where to find the log.h
header file and how to link against our libmylogger.so
library.
# Compile the application and link it against our shared library
# -I../mylogger: Tells the compiler to look in the ../mylogger directory for header files.
# -L../mylogger: Tells the linker to look in the ../mylogger directory for library files.
# -lmylogger: Tells the linker to link against the 'mylogger' library. The linker will find libmylogger.so.
# -o test_app: The name of our final executable.
gcc main.c -I../mylogger -L../mylogger -lmylogger -o test_app
# Check the result
ls -l
You should now have an executable file named test_app
in the app
directory.
Step 6: Running the Application and Solving the Final Puzzle
We have our library and our application. Let’s try to run it.
./test_app
You will likely be greeted with an error message similar to this:
./test_app: error while loading shared libraries: libmylogger.so: cannot open shared object file: No such file or directory
What happened? We told the linker where to find the library at compile time with the -L
flag, but we haven’t told the dynamic linker where to find it at run time. The dynamic linker, by default, only looks in a few standard locations (like /lib
and /usr/lib
). Our project directory isn’t one of them.
We have two common ways to solve this on a development system:
Method 1: Using the LD_LIBRARY_PATH
Environment Variable
This is the quickest method for testing. The LD_LIBRARY_PATH
variable gives the dynamic linker an extra list of directories to search for shared libraries.
# Prepend the path to our library to LD_LIBRARY_PATH and run the app
export LD_LIBRARY_PATH=../mylogger:$LD_LIBRARY_PATH
./test_app
Now, you should see the expected output:
[INFO] Application starting up.
[WARNING] Configuration file not found, using defaults.
[INFO] Doing work...
[INFO] Doing work...
[INFO] Doing work...
[ERROR] Failed to connect to the sensor device!
[INFO] Application shutting down.
Success! The dynamic linker found our library, loaded it, and the application ran correctly.
Warning: While
LD_LIBRARY_PATH
is convenient for development, it’s generally considered bad practice for production systems. It can be insecure and can lead to unpredictable behavior if multiple versions of the same library exist on the system.
Method 2: Installing the Library and Updating the Cache (The “Proper” Way)
For a production embedded system, you would install the library into a standard system directory and update the dynamic linker’s cache.
# First, let's copy our library to a standard location
# We'll use /usr/local/lib, which is a common place for custom libraries
sudo cp ../mylogger/libmylogger.so /usr/local/lib/
# Now, we need to tell the dynamic linker to update its cache of available libraries.
# The ldconfig command scans standard directories and creates the cache file /etc/ld.so.cache.
sudo ldconfig
# Unset our temporary LD_LIBRARY_PATH to prove this works
unset LD_LIBRARY_PATH
# Now run the application again
./test_app
The application should run perfectly again. This is the robust, production-ready method for deploying shared libraries on an embedded system. The system now knows about libmylogger.so
permanently (or until it’s removed).
Common Mistakes & Troubleshooting
Even with a clear process, developers new to shared libraries often encounter a few common pitfalls. Understanding these can save hours of frustrating debugging.
Exercises
- Create a Simple Math Library:
- Objective: Reinforce the basic library creation workflow.
- Task: Create a shared library named
libmymath.so
that provides three functions:int add(int a, int b);
,int subtract(int a, int b);
, andlong long multiply(int a, int b);
. Write a test application that uses these functions and prints the results. - Verification: The application should compile, link, and run correctly (using
LD_LIBRARY_PATH
), printing the correct mathematical results.
- Inspect Your Library with
readelf
:- Objective: Understand the internal structure of an ELF shared object.
- Task: Run the command
readelf -d libmymath.so
(from Exercise 1). Examine the output. - Verification: Identify the entry tagged as
(NEEDED)
or(SONAME)
. Find the(PLTGOT)
entry. This shows you the dynamic information the loader uses. Try to find other familiar tags.
- The Power of Updates:
- Objective: Demonstrate the key advantage of shared libraries—updating without recompiling the main application.
- Task:
- Modify the
multiply
function in yourlibmymath.so
library to print a debug message to the console, likeprintf("Inside multiply function!\\n");
. - Recompile only the shared library. Do not touch or recompile the test application from Exercise 1.
- Run the test application again.
- Modify the
- Verification: The application should now print the debug message when it calls the
multiply
function, proving that it loaded the new version of the library dynamically.
- Dependency Management:
- Objective: Understand how libraries can depend on other libraries.
- Task: Create a new “advanced math” library,
libadvmath.so
, with a functionlong long power(int base, int exp);
. Implement this function using the standard math library’spow()
function (which requires linking with-lm
). Now, write a test application that callspower()
and link it against bothlibadvmath
andm
. - Verification: Use the
ldd
command on your final test application (ldd ./your_app
). The output should show that your application depends onlibadvmath.so
, andlibadvmath.so
in turn depends onlibm.so
(the system math library).
- Troubleshooting Practice:
- Objective: Learn to recognize and fix common errors.
- Task: Intentionally make the mistakes described in the “Common Mistakes & Troubleshooting” section.
- Recompile
libmymath.so
without-fPIC
and observe the linker error. - Compile the test application but “forget” the
-lmylogger
flag and observe the “undefined reference” error. - Move
libmymath.so
to a new directory and run the test app without updatingLD_LIBRARY_PATH
to see the “cannot open shared object” error.
- Recompile
- Verification: Successfully identify, understand, and then fix each error to get the program working again.
Summary
- Static vs. Dynamic Linking: Static linking copies all code into the executable, creating large, self-contained files. Dynamic linking uses placeholders, loading shared code from
.so
files at runtime, which saves memory and simplifies updates. - Position-Independent Code (PIC): This is the core technology that allows shared library code to run correctly regardless of where it’s loaded in memory. It is enabled with the
-fPIC
compiler flag and is mandatory for all code destined for a shared library. - GOT and PLT: The Global Offset Table and Procedure Linkage Table are key mechanisms that enable PIC. They provide a layer of indirection for accessing global variables and functions, allowing the dynamic linker to resolve their true addresses at runtime.
- Creating a Shared Library: The process involves compiling source files to object files with
gcc -c -fPIC
and then linking those object files into a shared library withgcc -shared -o libname.so ...
. - Using a Shared Library: An application is compiled by telling the compiler where to find the library’s headers (
-I/path/to/headers
) and telling the linker where to find the library file and which one to use (-L/path/to/lib -lname
). - Runtime Linking: The dynamic linker must be able to find the
.so
file at runtime. This is achieved either by setting theLD_LIBRARY_PATH
environment variable or by installing the library in a standard system directory and runningldconfig
.
Further Reading
- ELF-64 Object File Format v1.5: The official specification for the ELF format. While dense, it is the ultimate source of truth. (Search for “System V Application Binary Interface AMD64 Architecture Processor Supplement”).
- GCC Command-Line Options: The official documentation for the GNU Compiler Collection, detailing the
-fPIC
and-shared
flags among many others. (https://gcc.gnu.org/onlinedocs/gcc/Link-Options.html) - How To Write Shared Libraries by Ulrich Drepper: An in-depth paper by a former lead glibc developer that covers the topic in exhaustive detail. It is a classic and highly authoritative resource.
ld.so(8)
Linux Manual Page: The man page for the dynamic linker/loader. It explains the search path rules,LD_LIBRARY_PATH
, and the cache file in detail. (man 8 ld.so
)readelf(1)
Linux Manual Page: The man page for thereadelf
utility, an indispensable tool for inspecting the contents of ELF files. (man 1 readelf
)- Computer Systems: A Programmer’s Perspective by Randal E. Bryant and David R. O’Hallaron: Chapter 7, “Linking,” provides an excellent university-level explanation of the entire linking process, including static, dynamic, PIC, and the role of the GOT and PLT.
- Raspberry Pi Documentation: Official hardware and software documentation for the Raspberry Pi, useful for platform-specific configurations. (https://www.raspberrypi.com/documentation/)