Creating a Shared Library -fPIC, -shared, soname, and Building .so Files

 

41.4 — Creating a Shared Library
-fPIC, -shared, soname, and Building .so Files | EmbeddedPathashala

Key Terms

-fPIC Position-Independent Code -shared flag -Wl,-soname Global Offset Table (GOT) Procedure Linkage Table (PLT) ldconfig readelf

What is Position-Independent Code (PIC)?

When a shared library is loaded by the dynamic linker, it can be placed at any virtual address in the process’s memory — the exact address is not known at compile time. This creates a problem: how can library code reference its own functions and global variables if it doesn’t know where it will be in memory?

The solution is Position-Independent Code (PIC). When you compile with -fPIC, gcc generates code that uses relative addressing instead of absolute addresses. It accesses global data through a Global Offset Table (GOT) and calls external functions through a Procedure Linkage Table (PLT). These tables are filled in at load time by the dynamic linker with the correct addresses.

The key benefit: because the code never hard-codes any absolute addresses, the same physical code pages can be mapped into different virtual addresses in different processes — enabling true memory sharing between processes.

How PIC Accesses Data: GOT
Library Code (.text)
mov rax, [rip + GOT_offset]
(relative, not absolute)
READ-ONLY — shared by all
GOT (.data)
[0] → actual address of var1
[1] → actual address of var2
PRIVATE per process
Actual Variable
var1 at address 0x7f…
Resolved at load time

Step-by-Step: Building a Shared Library

1
Compile source files with -fPIC
This generates position-independent object files. Do NOT forget this flag — without it, the linker will refuse to create a shared library (or produce one that won’t share memory properly).

$ gcc -g -fPIC -Wall -c gpio.c uart.c timer.c
2
Link into a shared library with -shared and set the soname
The -Wl,-soname,libname.so.1 flag embeds the soname into the .so’s ELF header.

$ gcc -g -shared -Wl,-soname,libembedutils.so.1 \
    -o libembedutils.so.1.0.0 \
    gpio.o uart.o timer.o
3
Create the soname and linker name symlinks

$ ln -sf libembedutils.so.1.0.0 libembedutils.so.1   # soname
$ ln -sf libembedutils.so.1     libembedutils.so       # linker name
4
Compile and link the program against the shared library

$ gcc -g -o myapp main.c -L. -lembedutils
5
Run the program (tell it where to find the .so)

## Option 1: Set LD_LIBRARY_PATH (for testing)
$ LD_LIBRARY_PATH=. ./myapp

## Option 2: Install to /usr/local/lib and run ldconfig (for deployment)
$ sudo cp libembedutils.so.1.0.0 /usr/local/lib/
$ sudo ldconfig
$ ./myapp

Coding Example 1 — Full Shared Library Build

A complete embedded I2C simulation library built and distributed as a shared library.

/* i2c_lib.h - I2C simulation library header */
#ifndef I2C_LIB_H
#define I2C_LIB_H

#define I2C_SUCCESS   0
#define I2C_ERR_NACK -1
#define I2C_ERR_BUS  -2

/* Initialize I2C bus at given frequency */
int i2c_init(int bus_num, int freq_hz);

/* Write bytes to a device */
int i2c_write(int bus_num, unsigned char addr,
              const unsigned char *data, int len);

/* Read bytes from a device */
int i2c_read(int bus_num, unsigned char addr,
             unsigned char *buf, int len);

/* Close the bus */
void i2c_close(int bus_num);

/* Get library version string */
const char* i2c_lib_version(void);

#endif /* I2C_LIB_H */
/* i2c_lib.c - I2C simulation implementation */
#include "i2c_lib.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

/* ========================================
 * IMPORTANT: This file must be compiled
 * with -fPIC to be used in a shared lib!
 * ======================================== */

#define MAX_BUS 4

static int bus_initialized[MAX_BUS] = {0};
static int bus_freq[MAX_BUS] = {0};

/* Simulated I2C device memory (for demo) */
static unsigned char device_memory[256] = {
    0x48, 0x00, 0x19, 0x00,  /* Temperature sensor regs */
    0xFF, 0x80, 0x64, 0x32,  /* Config regs */
    0, 0, 0, 0, 0, 0, 0, 0   /* Data regs */
};

const char* i2c_lib_version(void) {
    return "libi2c version 1.0.0 (shared)";
}

int i2c_init(int bus_num, int freq_hz) {
    if (bus_num < 0 || bus_num >= MAX_BUS) {
        fprintf(stderr, "[I2C] Invalid bus number: %d\n", bus_num);
        return I2C_ERR_BUS;
    }
    bus_initialized[bus_num] = 1;
    bus_freq[bus_num] = freq_hz;
    printf("[I2C] Bus %d initialized at %d Hz\n", bus_num, freq_hz);
    return I2C_SUCCESS;
}

int i2c_write(int bus_num, unsigned char addr,
              const unsigned char *data, int len) {
    if (!bus_initialized[bus_num]) {
        fprintf(stderr, "[I2C] Bus %d not initialized\n", bus_num);
        return I2C_ERR_BUS;
    }

    printf("[I2C] Write to device 0x%02X: ", addr);
    for (int i = 0; i < len; i++) {
        printf("0x%02X ", data[i]);
        /* Simulate writing to device memory */
        if (addr < 256) {
            device_memory[addr + i] = data[i];
        }
    }
    printf("... ACK\n");
    return I2C_SUCCESS;
}

int i2c_read(int bus_num, unsigned char addr,
             unsigned char *buf, int len) {
    if (!bus_initialized[bus_num]) {
        fprintf(stderr, "[I2C] Bus %d not initialized\n", bus_num);
        return I2C_ERR_BUS;
    }

    /* Simulate reading from device memory */
    for (int i = 0; i < len; i++) {
        buf[i] = (addr + i < 256) ? device_memory[addr + i] : 0xFF;
    }

    printf("[I2C] Read from device 0x%02X: ", addr);
    for (int i = 0; i < len; i++) {
        printf("0x%02X ", buf[i]);
    }
    printf("\n");

    return I2C_SUCCESS;
}

void i2c_close(int bus_num) {
    bus_initialized[bus_num] = 0;
    bus_freq[bus_num] = 0;
    printf("[I2C] Bus %d closed\n", bus_num);
}
/* main_i2c_app.c - Application using the I2C shared library */
#include <stdio.h>
#include "i2c_lib.h"

int main(void) {
    printf("=== I2C Application ===\n");
    printf("Using: %s\n\n", i2c_lib_version());

    /* Initialize I2C bus 0 at 400 kHz (fast mode) */
    int ret = i2c_init(0, 400000);
    if (ret != I2C_SUCCESS) {
        fprintf(stderr, "Failed to init I2C bus!\n");
        return 1;
    }

    /* Simulate writing config to a temperature sensor at address 0x48 */
    unsigned char config[] = {0x01, 0x60};  /* Pointer reg, config val */
    ret = i2c_write(0, 0x48, config, 2);

    /* Read temperature register (16-bit value) */
    unsigned char temp_buf[2];
    ret = i2c_read(0, 0x00, temp_buf, 2);

    /* Convert raw value to Celsius (simplified) */
    int raw = (temp_buf[0] << 4) | (temp_buf[1] >> 4);
    float temp_c = raw * 0.0625f;
    printf("\nTemperature: %.2f degrees C\n", temp_c);

    i2c_close(0);
    return 0;
}
## ============================================
## BUILD THE I2C SHARED LIBRARY
## ============================================

## Step 1: Compile with -fPIC (REQUIRED for shared lib)
$ gcc -g -fPIC -Wall -c i2c_lib.c -o i2c_lib.o

## Verify PIC was used — check for GOT references:
$ objdump -d i2c_lib.o | grep -i "got\|plt" | head -5

## Step 2: Create the shared library with proper soname
$ gcc -g -shared \
    -Wl,-soname,libi2c.so.1 \
    -o libi2c.so.1.0.0 \
    i2c_lib.o

## Verify the soname is embedded correctly:
$ readelf -d libi2c.so.1.0.0 | grep SONAME
## Output: (SONAME) Library soname: [libi2c.so.1]

## Step 3: Create symlinks
$ ln -sf libi2c.so.1.0.0 libi2c.so.1
$ ln -sf libi2c.so.1     libi2c.so
$ ls -la libi2c*

## Step 4: Compile the app
$ gcc -g -Wall -o i2c_app main_i2c_app.c -L. -li2c

## Step 5: Check what libs the app needs:
$ ldd i2c_app
## libi2c.so.1 => ./libi2c.so.1 (0x...)
## libc.so.6 => /lib/...

## Step 6: Run the app
$ LD_LIBRARY_PATH=. ./i2c_app

Coding Example 2 — PIC vs Non-PIC: What Goes Wrong?

This example shows what happens when you forget -fPIC and try to build a shared library. It also demonstrates using objdump to spot the difference.

/* test_pic.c - A simple module to test PIC compilation */
#include <stdio.h>

static int counter = 0;  /* global variable */

void increment(void) {
    counter++;
    printf("Counter = %d\n", counter);
}

int get_counter(void) {
    return counter;
}
## ============================================
## COMPARE: PIC vs non-PIC compilation
## ============================================

## Compile WITHOUT -fPIC (for static or direct use)
$ gcc -g -c test_pic.c -o test_pic_nopic.o

## Compile WITH -fPIC (required for shared library)
$ gcc -g -fPIC -c test_pic.c -o test_pic_pic.o

## Compare the disassembly — look for @PLT and @GOT references

## Non-PIC: uses absolute addresses (BAD for shared libs on x86-64)
$ objdump -d test_pic_nopic.o | grep -A3 "counter\|printf"

## PIC: uses RIP-relative addressing and @PLT stubs (GOOD)
$ objdump -d test_pic_pic.o | grep -A3 "counter\|printf"

## Try to build a shared lib WITHOUT -fPIC (will warn or fail):
$ gcc -shared -o test_nopic.so test_pic_nopic.o
## On modern x86-64, this may succeed but produce a warning or
## behave incorrectly. On x86-32, this WILL fail with relocation errors.

## Build correctly WITH -fPIC:
$ gcc -shared -Wl,-soname,libtest.so.1 \
    -o libtest.so.1.0.0 test_pic_pic.o
## No warnings — this is correct!

## ============================================
## INSPECT THE FINAL .so FILE
## ============================================

## See all exported symbols (functions programs can call):
$ nm -D libtest.so.1.0.0
## Output shows: increment, get_counter (T = exported text symbols)

## See the ELF header info:
$ readelf -h libtest.so.1.0.0 | grep "Type\|Entry"
## Type: DYN (Shared object file) — NOT EXEC!

## Check for GOT and PLT sections (present when -fPIC was used):
$ readelf -S libtest.so.1.0.0 | grep -E "GOT|PLT|DYNAMIC"

## See the full dynamic section (soname, needed libs, etc.):
$ readelf -d libtest.so.1.0.0

## Compare file sizes:
$ ls -lh test_pic_nopic.o test_pic_pic.o
## PIC object is slightly larger due to GOT/PLT indirection code
⚠️ Always use -fPIC for shared libraries:
On 32-bit x86, forgetting -fPIC will cause a hard linker error because the relocations cannot be resolved for shared use. On 64-bit x86 (x86-64), the linker may accept it (because x86-64 uses RIP-relative addressing by default) but the resulting .so file may use text relocations — these prevent memory sharing between processes and produce a security warning from the kernel (text segments with writable relocations are marked non-executable on SELinux/PaX systems).
💡 -fPIE vs -fPIC:
-fPIC is for shared libraries. -fPIE (Position-Independent Executable) is for executables compiled with ASLR support. Modern Linux systems compile programs with -fPIE by default for security. They generate similar code but serve different purposes: -fPIC allows multiple loads of the same library; -fPIE allows the kernel to randomize the executable’s base address (ASLR).

Interview Questions — Section 41.4

Q1. What is Position-Independent Code (PIC) and why is it needed for shared libraries?
PIC is machine code that uses relative addressing (via RIP-relative instructions and the GOT/PLT tables) instead of absolute memory addresses. It is needed for shared libraries because a shared library can be loaded at any virtual address in any process — the exact address is not known at compile time. With PIC, the same physical code pages can be shared across multiple processes mapped at different virtual addresses without modification. Without PIC, the code contains absolute addresses that would need to be fixed up (relocated) for each process, requiring private copies and preventing sharing.
Q2. What is the Global Offset Table (GOT)?
The GOT is a table of pointers in the data segment of a shared library. When PIC code needs to access a global variable or call an external function, it doesn’t use the absolute address directly. Instead, it looks up the address in the GOT at a relative offset from the instruction pointer (RIP). The dynamic linker fills in the GOT entries with actual addresses at load time. Because the GOT is in the data segment (which is per-process), different processes can have different values while sharing the same read-only code pages.
Q3. What is the purpose of the -Wl,-soname option?
-Wl,-soname,libfoo.so.1 is a linker option (passed through gcc’s -Wl to ld) that embeds the soname string into the ELF dynamic section of the shared library. When a program is linked against the library, the linker records this soname in the program’s ELF “NEEDED” entry (not the actual filename). At run time, the dynamic linker resolves this soname to a symlink, which points to the actual .so file. This is how version management works: updating the symlink allows transparently updating the library.
Q4. What is the Procedure Linkage Table (PLT)?
The PLT is a table of small code stubs (trampolines) used for lazy binding of external function calls. When PIC code calls an external function (like printf), it calls the PLT entry for that function. The first time it’s called, the PLT stub invokes the dynamic linker to resolve the actual address and stores it in the GOT. Subsequent calls go through the GOT directly without involving the dynamic linker. This “lazy” approach speeds up program startup because not all external functions need to be resolved immediately.
Q5. What command verifies that a shared library has the correct soname embedded?
Use readelf -d libname.so | grep SONAME to display the soname stored in the library’s ELF dynamic section. Alternatively, objdump -p libname.so | grep SONAME also works. For example:

$ readelf -d libembedutils.so.1.0.0 | grep SONAME
 0x0000000e (SONAME) Library soname: [libembedutils.so.1]

Leave a Reply

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