Position-Independent Code (PIC) The Linux Programming Interface

 

๐ŸŽฏ Position-Independent Code (PIC)
Chapter 41.4.2 | The Linux Programming Interface
Topic
-fPIC & GOT
Level
Advanced
Part
3 of 3

๐Ÿ”‘ Key Concepts

Position-Independent Code -fPIC flag Global Offset Table (GOT) _GLOBAL_OFFSET_TABLE_ TEXTREL Virtual Address Space Run-time Relocation nm / readelf / objdump

Why Does a Shared Library Need Special Code?

When you compile a normal executable, the compiler and linker know the exact memory address at which the program will be loaded (usually a fixed virtual address like 0x400000 on x86-64). So the compiler can generate instructions with hard-coded addresses โ€” for example, mov eax, [0x601020] to access a global variable.

A shared library cannot have hard-coded addresses. The library might be loaded at 0x7f8a12300000 in one process and at 0x7f2b99100000 in another, depending on what else was already loaded. The load address is determined at run time, not compile time.

Position-Independent Code (PIC) solves this problem by generating code that works correctly regardless of the address at which it is placed in memory. This is what -fPIC instructs GCC to produce.

๐Ÿ”ง What Does -fPIC Actually Change?

The -fPIC flag changes how the compiler generates machine instructions for several common operations. Instead of referencing absolute memory addresses, PIC code uses relative addressing and an indirection table called the Global Offset Table (GOT).

โŒ Non-PIC (hard-coded address)
; access global variable
mov eax, [0x601020] ; absolute!
โœ— Only works if loaded at
the expected base address.
Each process needs its own
private copy of these pages.

โœ… PIC (GOT-relative)
; access global variable
call __x86.get_pc_thunk
add eax, offset_to_GOT
mov eax, [eax + var_offset]
โœ“ Works at any load address.
GOT is patched at load time.
Code pages can be shared
across all processes.

The -fPIC flag affects how the compiler handles these specific situations:

Operation Without -fPIC With -fPIC
Access global variable Absolute address in instruction Indirect via GOT entry
Access static variable Absolute address in instruction PC-relative or GOT-relative
Access external variable Absolute address (requires TEXTREL) GOT entry (patched at load time)
Access string constant Absolute address in .rodata PC-relative offset
Function call (internal) Direct call with absolute address PC-relative call (relative offset)
Function call (external) Absolute address via PLT PLT entry โ†’ GOT entry โ†’ lazy resolve

๐Ÿ“‹ The Global Offset Table (GOT) โ€” How PIC Works

The key mechanism behind PIC is the Global Offset Table (GOT). This is a table of pointers stored in the data segment of the shared library. Each entry holds the absolute run-time address of a symbol (global variable or function).

At run time, when the dynamic linker loads the library, it patches the GOT entries with the correct absolute addresses for that process’s virtual address space. The code itself never changes โ€” only the GOT (which is per-process data) gets patched.

Process Virtual Address Space
.text segment
(shared โ€” same physical pages for all processes)

call [GOT+8]
instruction refers to GOT offset, never absolute address

โ†’ GOT (in .data)
(private per-process โ€” patched by dynamic linker)

GOT[0]: 0x7f8a12345600 โ† addr of global_var
GOT[1]: 0x7f8a12348200 โ† addr of ext_func
GOT[2]: 0x7f8a1234a000 โ† addr of another sym

The symbol _GLOBAL_OFFSET_TABLE_ appears in the symbol table of any object file compiled with -fPIC. Its presence is how you verify that PIC was used.

โ“ Why Is This Necessary for Shared Libraries?

The reason is fundamental to how virtual memory and shared memory work. When the kernel maps a shared library into multiple processes, it maps the same physical memory pages for the code (text) segment. If that code contained absolute addresses, those addresses would only be correct for the one process that happens to have the library at that exact virtual address.

With PIC, the code is truly read-only and address-agnostic. Every process sees the same machine instructions, which access data through the GOT. The GOT differs per process (it lives in private data pages), so each process can have the library at a different virtual address and still work correctly.

Memory saving mechanism: Because PIC code is identical for all processes, the kernel can mark those pages as read-only and physically share them (copy-on-write is not needed). Non-PIC code with embedded absolute addresses would need to be modified per-process, breaking sharing.
On Linux/x86-32: You can build a shared library without -fPIC, but each process needs its own modified copy of the text pages containing absolute addresses. This wastes RAM and defeats the purpose of shared libraries. On x86-64 and ARM, the ABI makes -fPIC effectively mandatory for shared libraries.

โšก Performance Overhead of PIC

PIC code has a small performance overhead compared to non-PIC code. The overhead comes from:

  • Extra register usage: On 32-bit x86, PIC code uses an extra register (typically ebx) to hold the GOT pointer. This register is no longer available for general computation, which can force the compiler to spill other values to the stack. On 64-bit x86 (x86-64), this overhead is negligible because PC-relative addressing is directly supported in the instruction set.
  • Indirect memory accesses: Accessing a global variable requires one extra memory load (fetch the address from the GOT, then fetch the variable). Modern CPUs with large caches make this overhead negligible in practice.

In practice, the PIC overhead is small enough that most modern applications do not notice it, especially on 64-bit systems. The benefits of shared libraries far outweigh this cost.

๐Ÿ” Checking Whether Code Is Compiled with -fPIC

Two ways to check if an object file was compiled with -fPIC:

# Method 1: nm โ€” look for _GLOBAL_OFFSET_TABLE_ symbol
nm mod1.o | grep _GLOBAL_OFFSET_TABLE_

# If PRESENT:  file was compiled WITH -fPIC
# If ABSENT:   file was compiled WITHOUT -fPIC

# Method 2: readelf โ€” same check via ELF symbol table
readelf -s mod1.o | grep _GLOBAL_OFFSET_TABLE_

Two ways to check if a shared library contains non-PIC code:

# Method 1: objdump โ€” look for TEXTREL flag
objdump --all-headers libfoo.so | grep TEXTREL

# Method 2: readelf โ€” check dynamic section
readelf -d libfoo.so | grep TEXTREL

# TEXTREL present = at least one module was NOT compiled with -fPIC
# This means those text pages CANNOT be shared between processes
# Each process gets its own modified copy โ€” memory is wasted

๐Ÿ’ป Coding Examples

Example 1 โ€” Seeing PIC vs Non-PIC in Assembler Output

This example compares the assembler output of GCC with and without -fPIC. You can see how global variable access changes.

/* pic_demo.c โ€” accesses a global variable and calls an external function */
#include <stdio.h>

int global_counter = 0;   /* global variable โ€” in .data */

void increment_global(void) {
    global_counter++;
}

int get_global(void) {
    return global_counter;
}
# Compile WITHOUT -fPIC and dump assembly
gcc -O0 -c pic_demo.c -o no_pic.o
objdump -d no_pic.o
# In the output you will see direct absolute-address references
# Example (x86-32): mov 0x0, %eax  (address 0x0 to be patched by linker)

# Compile WITH -fPIC and dump assembly
gcc -O0 -c -fPIC pic_demo.c -o with_pic.o
objdump -d with_pic.o
# In the output you see GOT-relative addressing
# Example: call __x86.get_pc_thunk.ax; add $_GLOBAL_OFFSET_TABLE_, %eax

# Verify _GLOBAL_OFFSET_TABLE_ presence
nm no_pic.o | grep GLOBAL_OFFSET   # no output
nm with_pic.o | grep GLOBAL_OFFSET # shows the symbol

# Check object file sizes (with_pic.o slightly larger)
ls -l no_pic.o with_pic.o
# On x86-64 the difference is less dramatic because x86-64 has
# native PC-relative addressing. On 32-bit x86 the difference
# in assembly is much more visible.
# To see 32-bit output on 64-bit machine:
gcc -m32 -O0 -c pic_demo.c -o no_pic_32.o
gcc -m32 -O0 -c -fPIC pic_demo.c -o with_pic_32.o
objdump -d no_pic_32.o
objdump -d with_pic_32.o
Example 2 โ€” Complete PIC Verification Workflow

This is a practical script that builds a library, deliberately introduces a non-PIC module, and shows how the TEXTREL check detects the problem.

/* good.c โ€” compiled WITH -fPIC */
#include <stdio.h>
int good_value = 42;
void good_func(void) {
    printf("good_func: good_value = %d\n", good_value);
}
/* bad.c โ€” will be compiled WITHOUT -fPIC (intentionally) */
#include <stdio.h>
int bad_value = 99;
void bad_func(void) {
    printf("bad_func: bad_value = %d\n", bad_value);
}
# Step 1: Compile good.c correctly WITH -fPIC
gcc -c -fPIC -Wall good.c -o good.o

# Step 2: Compile bad.c WITHOUT -fPIC (simulates a mistake)
gcc -c -Wall bad.c -o bad.o

# Step 3: Check each .o for _GLOBAL_OFFSET_TABLE_
echo "=== good.o (should have GOT symbol) ==="
nm good.o | grep _GLOBAL_OFFSET_TABLE_ || echo "NOT FOUND โ€” not PIC!"

echo "=== bad.o (should be missing GOT symbol) ==="
nm bad.o | grep _GLOBAL_OFFSET_TABLE_ || echo "NOT FOUND โ€” not PIC!"

# Step 4: Build shared library mixing PIC and non-PIC objects
# gcc will warn about this on some systems
gcc -shared -o libmixed.so good.o bad.o
# Step 5: Check the resulting .so for TEXTREL
echo "=== TEXTREL check on libmixed.so ==="
readelf -d libmixed.so | grep TEXTREL
# If bad.o was non-PIC you WILL see TEXTREL here

echo "=== Build a correct library (all -fPIC) ==="
gcc -c -fPIC -Wall bad.c -o bad_pic.o    # recompile WITH -fPIC
gcc -shared -o libgood.so good.o bad_pic.o

echo "=== TEXTREL check on libgood.so (should be empty) ==="
readelf -d libgood.so | grep TEXTREL || echo "No TEXTREL โ€” all modules are PIC"

# Step 6: Verify _GLOBAL_OFFSET_TABLE_ in both .o files
nm good.o | grep GLOBAL_OFFSET_TABLE
nm bad_pic.o | grep GLOBAL_OFFSET_TABLE
# Both should show the symbol now
# Step 7: Bonus โ€” use 'file' to confirm the .so type
file libgood.so
# ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
# dynamically linked, not stripped

# Step 8: Use readelf to see GOT in section headers
readelf -S libgood.so | grep -E "(Name|\.got|\.plt)"
# You will see .got, .got.plt sections in the output

๐Ÿ“ Quick Reference Cheatsheet
Task Command
Compile source with PIC gcc -c -fPIC -Wall source.c
Build shared library gcc -shared -o libfoo.so *.o
Check .o for PIC nm mod.o | grep _GLOBAL_OFFSET_TABLE_
Check .so for non-PIC modules readelf -d libfoo.so | grep TEXTREL
List exported symbols nm -D libfoo.so
Show dynamic dependencies ldd prog
Show ELF sections readelf -S libfoo.so
Disassemble library code objdump -d libfoo.so
Set run-time lib path (dev only) export LD_LIBRARY_PATH=.
Embed run-time path in binary gcc -Wl,-rpath,/usr/local/lib ...

๐ŸŽฏ Interview Questions

Q1. What is Position-Independent Code (PIC) and why is it required for shared libraries?

Answer: PIC is code that can execute correctly regardless of the absolute memory address at which it is loaded. Shared libraries need PIC because the dynamic linker determines the load address at run time โ€” it cannot be known at compile time. PIC uses relative addressing and the Global Offset Table (GOT) for data references, so the code itself never contains absolute addresses and can be shared (same physical pages) across multiple processes.

Q2. What is the Global Offset Table (GOT) and what purpose does it serve?

Answer: The GOT is a table of pointers in the data segment of a shared library. Each entry holds the absolute run-time address of a symbol (global variable or function). PIC code does not embed absolute addresses in instructions; instead it loads addresses from GOT entries. When the dynamic linker loads the library, it fills in the GOT entries with the correct virtual addresses for that process. Because the GOT is in the data segment (not the code/text segment), it can differ per process while the code pages remain shared.

Q3. What does the presence of TEXTREL in a shared library indicate?

Answer: TEXTREL stands for “text segment relocation.” Its presence in the dynamic section of a .so file means at least one object module was compiled without -fPIC. This object contains absolute address references in its code (text) segment that must be patched at load time. Because patching the text segment makes it different per process, those pages cannot be shared between processes โ€” each process needs its own modified copy, wasting physical memory.

Q4. What symbol in an object file’s symbol table confirms it was compiled with -fPIC?

Answer: The presence of the symbol _GLOBAL_OFFSET_TABLE_ in the object file’s symbol table confirms it was compiled with -fPIC. You can check with nm mod.o | grep _GLOBAL_OFFSET_TABLE_ or readelf -s mod.o | grep _GLOBAL_OFFSET_TABLE_.

Q5. Why is the performance overhead of PIC greater on 32-bit x86 than on x86-64?

Answer: On 32-bit x86, the processor does not support PC-relative data addressing in its instruction set. To implement PIC, the compiler must dedicate one general- purpose register (typically ebx) to hold the GOT pointer. This reduces the number of registers available for other computations, which can force additional stack operations. On x86-64, the instruction set has native RIP-relative addressing, so PIC data access can use that directly without tying up a register, making the overhead negligible.

Q6. Can you build a shared library on Linux without -fPIC? What are the consequences?

Answer: On Linux/x86-32 it is technically possible to build a shared library without -fPIC. The consequence is that the text segment pages of that library will contain absolute addresses that need run-time patching. These pages will be marked with TEXTREL and cannot be shared between processes โ€” each process gets its own private modified copy of those pages, negating the memory-saving benefit of shared libraries. On some architectures (ARM, x86-64 in strict mode) it may be impossible entirely.

Q7. What is the difference between how PIC handles an internal function call versus an external function call?

Answer: For an internal function call (within the same shared library), PIC uses a PC-relative call instruction. Since the caller and callee are at a fixed relative offset from each other within the same .so, the offset is constant regardless of load address โ€” no GOT lookup needed. For an external function call (a function in another library), PIC goes through the PLT (Procedure Linkage Table), which uses a GOT entry to find the actual function address โ€” this is looked up (and cached) at first call via the lazy binding mechanism.

You’ve Completed Chapter 41 (Sections 41.3โ€“41.4.2)

โ—€ Prev: Creating a Shared Library ๐Ÿ  Back to Overview

Leave a Reply

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