Position-Independent Code and PIE
Position-independent code (PIC) is machine code that executes correctly regardless of the absolute address it is loaded at, because it never hard-codes an absolute address for a global symbol — it reaches globals relative to the current instruction pointer or through the Global Offset Table (GOT). This is a requirement for shared libraries, whose load address is not known until runtime, and it is produced with the compiler flag
-fPIC(or-fpic). A position-independent executable (PIE) applies the same idea to the main program: built with-fPIE -pie, the executable is emitted as an ELFET_DYNobject — structurally a shared object with an entry point — so the kernel can load it at a random base address on every run. That randomization is the whole point: PIE is what lets Address Space Layout Randomization (ASLR) cover the executable’s own code and data, not just its libraries, which is why security-hardened distributions build everything as PIE (Red Hat). Critically, PIE is a distribution/toolchain policy, not a universal compiler default — a distinction this note draws carefully.
For the two indirection tables PIC relies on, see The Global Offset Table and The Procedure Linkage Table; for the resolution work at load time, Relocation Processing and The Dynamic Linker.
Mental Model
Non-position-independent code answers “where is global g?” by embedding a number — g’s absolute address — into the instruction stream at link time. That is fast but fixes the code to one load address. PIC answers the same question with a computation: “g is at my current instruction pointer plus a link-time-known constant” (for symbols known to be in the same module), or “g’s address is stored in my GOT, at a fixed offset from my instruction pointer” (for symbols that might be elsewhere). Because every address is expressed relative to the code itself, the whole module can be slid to any base address and still work. PIE is simply “make the executable one of these slidable modules too.”
flowchart TB subgraph NONPIC["Non-PIC / ET_EXEC (fixed base)"] A["mov 0x404050, %eax<br/>absolute address baked in"] --> A2["loads at a FIXED<br/>virtual address"] end subgraph PIC["PIC / PIE / ET_DYN (slidable)"] B["mov g@GOTPCREL(%rip), %rax<br/>then (%rax)"] --> B2["address computed<br/>relative to %rip"] B2 --> B3["kernel picks random<br/>load bias each run (ASLR)"] end
What it shows: non-PIC embeds absolute addresses and must load at a fixed base (ET_EXEC); PIC/PIE express every reference relative to the instruction pointer, so the loader is free to choose a random base (ET_DYN). The insight to take: the relative addressing is not merely a linking convenience — it is precisely the property that allows ASLR to randomize the executable, which is why “PIE” and “ASLR of the main binary” are the same feature seen from two sides.
Mechanical Walk-through
Why shared libraries must be PIC. A .so is mapped at a runtime-chosen address. If its code contained absolute addresses, the dynamic linker would have to rewrite the code text at load — a text relocation. Drepper is emphatic that this is unacceptable: non-PIC library code “almost certainly will contain text relocations. For these there is no excuse. Text relocations require extra work to apply in the dynamic linker. And argumentation saying that the code is not shared … is invalid” (Drepper §2). Rewriting text also defeats page sharing (each process gets a private, modified copy) and requires the pages to be temporarily writable — a security problem. PIC avoids all of this by never needing to patch the code.
RIP-relative addressing (the x86-64 lever). x86-64 has a program-counter-relative addressing mode (%rip-relative) that makes PIC nearly free. To read a local global, the compiler emits a direct RIP-relative load; to read an external global (which might be interposed), it loads the address from the GOT: movq g@GOTPCREL(%rip), %rax; movl (%rax), %eax (MaskRay GOT). The GOT slot is at a link-time-known offset from the code, so no text relocation is needed — only a GOT slot to fill.
The historical cost, and why it collapsed on x86-64. Drepper’s IA-32 (32-bit x86) examples show PIC’s old price: 32-bit x86 has no PC-relative data addressing, so PIC code had to first compute the address of the GOT into a register (%ebx) via a call/pop sequence and keep it reserved — “the use of %ebx as the PIC register deprives the compiler of one of the precious registers” (Drepper §2). On register-starved IA-32 this was a real slowdown. x86-64 fixed both problems: it has RIP-relative addressing and eight more general registers, so “in modern compilers … the handling of the PIC register is much more flexible” and the overhead “is not noticeable.” Red Hat’s measurements agree: on IA-32 “another register is required,” but on x86-64 “performance post process execution is largely comparable to standard Dynamic Shared Objects” and PIE vs non-PIE “start time … were comparable” (Red Hat).
-fpic vs -fPIC. These differ only in whether the compiler assumes the GOT is small enough for a short offset. -fpic “tells the compiler that the size of the GOT does not exceed an architecture-specific value (8kB in case of SPARC),” letting it use a compact GOT-relative form; -fPIC makes no such assumption and always emits the full-width sequence (Drepper §2). On x86-64 the difference is negligible; on architectures with GOT-size limits, exceeding the limit under -fpic makes the linker error and demand a recompile with -fPIC.
-fPIC vs -fPIE. Both produce position-independent code, but they encode a different assumption about interposition. -fPIE tells the compiler the code will end up in the main executable, which is always first in the lookup scope, so references to symbols defined in the executable are known to bind locally and can be accessed directly (no GOT, no PLT). -fPIC (for a library) must assume any exported symbol could be interposed by an earlier module, so it goes through the GOT/PLT. This is why PIE code is often slightly faster than PIC library code for the same source: the executable-first guarantee removes indirections. (This same guarantee is what enables the copy-relocation edge case discussed in Copy Relocations.)
PIE = ET_DYN executable + a random load bias. A PIE’s ELF header type is ET_DYN — the same as a .so — not ET_EXEC. As eklitzke puts it, “there is still an entry point for the program with a hard-coded address, but all the entry point does is set things up to run the relocatable code,” and “when a PIE ASLR binary starts up the kernel picks a random virtual memory address to load all code … at” (eklitzke). That random offset is the load bias; because the code is position-independent, adding a bias to every mapping “just works.” readelf -h reports a PIE as Type: DYN (Position-Independent Executable file) in recent binutils.
How the executable’s own relocations get applied. A PIE still has internal absolute references (e.g. a pointer initialized to &some_global). These are handled by R_X86_64_RELATIVE relocations (calculation B + A: load base plus addend) in .rela.dyn, applied by the dynamic linker (or, for static-PIE, by self-relocation) after the base is chosen. Red Hat notes “the binary structure includes a .rela.dyn relocation section, and addresses are ‘relative address[es]’” (Red Hat). This is why a PIE has more startup relocations than a non-PIE — every absolute pointer becomes a RELATIVE reloc. (The DT_RELR/R_*_RELR compact relative-relocation format was introduced to shrink exactly this cost.)
Static-PIE. A statically linked program can also be a PIE. glibc 2.27 (Feb 2018) added --enable-static-pie, which “embed[s] the part of ld.so in [the] static executable to create static position independent executable” (LWN 2.27). A new function _dl_relocate_static_pie “get[s] the run-time load address, read[s] the dynamic section, and perform[s] dynamic relocations,” and “is called at entrance of LIBC_START_MAIN in libc.a.” Building one needs gcc -static-pie (GCC 8+ for the driver flag), giving a single self-contained binary that also gets ASLR — closing the old gap where static binaries loaded at a fixed address (see Static vs Dynamic Linking).
Code and Specification
Building the three variants and inspecting the ELF type:
$ gcc -fPIC -shared -o libfoo.so libfoo.c # PIC shared library (ET_DYN)
$ gcc -fPIE -pie -o app_pie app.c # PIE executable (ET_DYN)
$ gcc -fno-pie -no-pie -o app_exec app.c # classic fixed exe (ET_EXEC)
$ gcc -static-pie -o app_spie app.c # static PIE (ET_DYN)
$ readelf -h app_pie | grep Type
Type: DYN (Position-Independent Executable file)
$ readelf -h app_exec | grep Type
Type: EXEC (Executable file)Verifying ASLR actually randomizes a PIE’s base (the load address changes each run; a non-PIE’s does not):
$ cat /proc/self/maps | head -1 # run twice; PIE base differs, ET_EXEC base fixedChecking for the text relocations that PIC exists to avoid — Drepper’s diagnostic (Drepper §2):
$ readelf -d libfoo.so | grep TEXTREL # any output here = code was NOT position-independentThe x86-64 code difference, from Drepper’s discussion, contrasting a fixed-address access with the GOT-indirect PIC access:
; non-PIC: address of g baked into the instruction
mov g, %eax
; PIC/PIE: address of g loaded from the GOT, GOT reached RIP-relative
movq g@GOTPCREL(%rip), %rax
movl (%rax), %eaxThe Default: Distro Policy vs Upstream Compiler
This is the single most-misstated fact about PIE, and the vault’s binding guidance is to keep it straight. Upstream GCC does not build PIE by default. PIE-by-default is enabled by a configure-time build option, --enable-default-pie, added in GCC 6 (2016): “The option is a configure-time choice, meaning it’s not enabled in upstream GCC by default — distributions must explicitly enable it when building their GCC packages.” Distributions then opt in: Ubuntu 16.10 “enabled PIE and immediate binding by default on amd64 and ppc64le,” Arch enables --enable-default-pie, Gentoo Hardened compiles with PIE, and Fedora uses --enable-default-pie “to ensure PIE support is enabled across all architectures” (Fedora HardeningFlags28). GCC’s own documentation confirms the mechanism is packager-controlled: “Position Independent Executable PIE Enabled by Default on Linux” is described as a property of how the toolchain was built (GCC/GNAT manual).
The practical consequence: whether gcc hello.c produces a PIE or an ET_EXEC depends entirely on which distribution’s GCC you invoke. A vanilla GCC built from source with defaults produces ET_EXEC; Fedora’s or Ubuntu’s GCC produces a PIE. Never state “GCC defaults to PIE” as a universal fact — it is true only of distro-patched toolchains, and it is exactly parallel to the full-RELRO/-z now situation where Fedora/Debian defaults are distro policy layered on top of upstream tools.
Uncertain
Verify: the precise as-of-2026 PIE-default status per distribution and architecture (which arches Fedora/RHEL/Debian/Ubuntu/Arch build PIE-by-default, and any exceptions). Reason: PIE defaults are per-distro, per-arch, and have shifted over releases; my sources pin the mechanism (
--enable-default-pie, distro opt-in) and several distro adoptions but not a complete current matrix, and 32-bit ARM/other arches have historically lagged. To resolve: check each distro’s currentredhat-rpm-config/dpkg-buildflags/makepkghardening defaults and confirm withreadelf -hon a stock binary. uncertain
Failure Modes and Common Misunderstandings
“PIC and PIE are the same thing.” They share the addressing model but differ in interposition assumptions (-fPIE assumes executable-first, -fPIC assumes any symbol may be interposed) — mixing them wrongly can defeat interposition or, conversely, add needless indirection. And they differ in ELF type: a PIE is ET_DYN; a PIC object file is not itself runnable.
A PIE is ET_DYN, which confuses tools. Because a PIE and a shared library are both ET_DYN, file historically reported PIEs as “shared object,” and some tooling could not tell whether an ET_DYN was meant to be executed or dlopened. Newer binutils distinguishes them (the “Position-Independent Executable file” wording, and a DF_1_PIE flag). This is a frequent source of “is this a program or a library?” confusion.
PIE without ASLR buys nothing. PIE is necessary for full ASLR but not sufficient — if the kernel’s randomize_va_space is disabled (/proc/sys/kernel/randomize_va_space = 0) or a personality flag turns randomization off, a PIE loads at a predictable base and the security benefit evaporates. Conversely a non-PIE always has a fixed-base executable no matter the ASLR setting; only its libraries randomize.
Startup cost is real but small on x86-64. A PIE has more RELATIVE relocations than a non-PIE, so it does marginally more work at startup. On x86-64 this is negligible (Red Hat’s “comparable start time”), but on 32-bit or relocation-heavy binaries it can be measurable — the motivation for the compact DT_RELR format. Do not conflate this with the steady-state cost, which RIP-relative addressing makes essentially zero on x86-64.
Alternatives and When to Choose Them
- Non-PIE (
-no-pie -fno-pie). Fixed-base executable. Slightly fewer startup relocations and, on 32-bit, faster code; but the main binary’s code/data are not ASLR-randomized, so it is a weaker security posture. Reasonable for tightly performance-bound 32-bit workloads or when a fixed load address is a hard requirement. - PIE (
-fPIE -pie). The security-hardening default on modern distros; near-zero steady-state cost on x86-64; full ASLR coverage. Choose it for anything network-facing or privileged. - Static-PIE (
-static-pie). Combines the self-contained single-binary benefit of static linking (see Static vs Dynamic Linking) with ASLR. Choose it for containers/minimal images that want both no runtimeld.sodependency and randomized layout — at the cost of the usual static-linking downsides (no NSS plugins, larger binary, no per-library security updates).
Production Notes
In production, essentially every mainstream distribution ships its programs as PIE with full RELRO and -z now, so on a stock Fedora/RHEL/Debian/Ubuntu system readelf -h /usr/bin/* shows DYN almost universally. The famous vault finding is worth restating precisely: Fedora (and peers) ship PIE + full RELRO + BIND_NOW as distribution policy, implemented through packaging flags (redhat-rpm-config, --enable-default-pie), not as an upstream GCC/binutils default. When you build software outside that packaging — a from-source GCC, a cross-toolchain, a minimal container image — you may get ET_EXEC and partial RELRO unless you pass the flags yourself. The operational tells: readelf -h for DYN vs EXEC, checksec/hardening-check for a combined PIE/RELRO/BIND_NOW report, and readelf -d | grep TEXTREL to catch a supposedly-PIC library that accidentally embedded absolute addresses (a build bug that silently disables page sharing). For debugging ASLR-related non-reproducibility, temporarily setarch -R (disable randomization) to pin the PIE’s base and get stable addresses across runs.
See Also
- The Global Offset Table — the indirection table PIC uses for external data/functions
- The Procedure Linkage Table — the call-side indirection PIC/PIE rely on
- RELRO Relocation Read-Only — the sibling hardening; also distro-default, also often misstated
- Copy Relocations — the PIE-vs-non-PIE data-access edge case (
HAVE_LD_PIE_COPYRELOC) - Relocation Processing — where a PIE’s
R_*_RELATIVErelocations are applied - GNU IFUNC and Indirect Functions — static-PIE self-relocation of
IRELATIVEvia_dl_relocate_static_pie - Static vs Dynamic Linking — static-PIE sits at the intersection
- The Dynamic Linker — chooses the load bias and applies relocations
- Linux Userspace Runtime MOC — parent map