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 ELF ET_DYN object — 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.
This topic straddles two authorities — read each claim against the right one
PIE is unusual among the notes in this area because half of it is a toolchain story and half of it is a kernel story, and confusing the two produces most of the wrong statements in circulation. The split is clean:
Half
What it decides
Who owns it
Authority used in this note
Code generation and ABI
whether a reference to a global compiles to a PC-relative load, a GOT load, or an absolute address; whether the output is ET_EXEC or ET_DYN; which relocation types appear
where an ET_DYN executable is actually placed, how many bits of entropy each region gets, and whether randomization happens at all
the Linux kernel
Linux v6.12 (longterm; 6.12.109 as of 2026-09-11 per kernel.org/releases.json): fs/binfmt_elf.c, arch/x86/mm/mmap.c, arch/x86/kernel/process.c, arch/x86/include/asm/elf.h, mm/util.c, and Documentation/admin-guide/sysctl/kernel.rst
Concretely: “a PIE is ET_DYN with RIP-relative code” is a toolchain fact, unaffected by which kernel you boot. “a PIE’s base address gets 28 bits of entropy” is a kernel fact, unaffected by which compiler built it — and it changes with architecture, with /proc/sys/vm/mmap_rnd_bits, and with randomize_va_space. The two halves meet at exactly one point: the kernel will only randomize the executable’s base if the ELF type is ET_DYN, because an ET_EXEC names its own load addresses and the kernel must honour them. That single dependency is the entire reason PIE exists as a security feature.
Kernel version pinning in this note
Kernel claims below are read from Linux v6.12 source, per this vault’s kernel pin. They were then checked against the 7.1.13-200.fc44 kernel actually running on the test machine by measuring the behaviour they predict; every prediction held (the derivations are shown). Where v6.12 and the running kernel could differ, the note says so. As of 2026-09-11 mainline is 7.3-rc2, latest stable 7.2.5, and 6.12 and 6.18 are both longterm.
The measurement environment for this note
Component
Version
Distribution
Fedora 44
Kernel (running)
7.1.13-200.fc44.x86_64
Kernel (source pinned for ASLR claims)
v6.12
Compiler
gcc (GCC) 16.2.1 20260819 (Red Hat 16.2.1-2)
Linker / readelf / objdump
binutils 2.46.1-1.fc44
C library / loader
glibc 2.43-8.fc44
/proc/sys/kernel/randomize_va_space
2
CONFIG_ARCH_MMAP_RND_BITS
28 (min 28, max 32)
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.
That diagram is the textbook model, and on x86-64 it is not what the compiler emits
The mov 0x404050, %eax form in the left-hand box is what the mental model says non-PIC code looks like. It is accurate for 32-bit x86 and for the general idea, but GCC 16 on x86-64 does not generate it for ordinary globals in either mode. Measured below: for a plain global read, -fno-pie and -fPIE produce byte-identical machine code, both RIP-relative. The real difference on x86-64 is narrower and more specific than the textbook picture, and knowing where it actually lives is what lets you reason correctly about the cost. Keep the diagram for the concept; read the next section for the machine.
What the compiler actually emits — three modes, same source
The cleanest way to see the difference is a four-line translation unit that exercises each interesting case: a read of a global defined elsewhere, a read of a global defined here, and a call to an external function.
extern int external_global; /* defined in another module */int local_global = 7; /* defined here */extern void external_fn(void);int read_external(void) { return external_global; }int read_local(void) { return local_global; }void call_external(void) { external_fn(); }
Compiled three ways at -O2 with GCC 16.2.1 (comments added):
;;;;;;;;;;;;;;;; gcc -O2 -fno-pie -Sread_external: movl external_global(%rip), %eax ; RIP-relative, directread_local: movl local_global(%rip), %eax ; RIP-relative, directcall_external: jmp external_fn ; direct, no @PLT;;;;;;;;;;;;;;;; gcc -O2 -fPIE -Sread_external: movl external_global(%rip), %eax ; IDENTICAL to -fno-pieread_local: movl local_global(%rip), %eax ; IDENTICAL to -fno-piecall_external: jmp external_fn@PLT ; only difference: PLT reloc;;;;;;;;;;;;;;;; gcc -O2 -fPIC -Sread_external: movq external_global@GOTPCREL(%rip), %rax ; GOT indirection movl (%rax), %eax ; then loadread_local: movq local_global@GOTPCREL(%rip), %rax ; even the LOCAL one movl (%rax), %eaxcall_external: jmp external_fn@PLT
Three things fall out of that, and each one corrects a common belief:
Non-PIC x86-64 code is already PC-relative for global data. x86-64’s RIP-relative addressing mode is used unconditionally by GCC for mov reg, symbol because it is shorter and faster than an absolute displacement. So “non-PIC bakes in absolute addresses” is not true of the common case on this architecture; it is true of 32-bit x86, and it is true of a narrower set of x86-64 patterns shown below.
-fPIE and -fno-pie generate the same instructions here. Not similar — the same. Verified at the byte level:
The relocation tables are identical too — R_X86_64_PC32 for both globals, R_X86_64_PLT32 for the call, in both objects. For this translation unit, -fPIE is free in the most literal sense available: it changes nothing.
-fPIC is the mode that costs something, and it costs it even for local_global. That is not inefficiency, it is the interposition contract: a symbol exported from a shared library can be preempted by an earlier module in the lookup scope, so the compiler must reload the address from the GOT rather than assume the definition it can see is the one that wins. -fPIE is exempt because the main executable is always first in the lookup scope and cannot be preempted. The relocation types make the distinction explicit:
R_X86_64_REX_GOTPCRELX is the relaxable GOT relocation: the X suffix tells the linker it may rewrite the movq sym@GOTPCREL(%rip), %rax back into a leaq sym(%rip), %rax if it can prove at link time that the symbol binds locally after all (for example in a -Bsymbolic or --no-undefined-version link). So even PIC’s cost is partly reclaimed by the linker.
What PIE Actually Costs on x86-64 — Measured
The “PIE is slow” claim is repeated constantly and almost never with a number attached. It has a real basis on 32-bit x86 and a much smaller one on x86-64, and the difference is worth pinning down precisely rather than hand-waving in either direction.
The one addressing pattern where PIE genuinely costs an instruction
x86-64’s RIP-relative addressing mode has a hard architectural limitation: it cannot be combined with an index register.mov sym(%rip), %rax is legal; mov sym(%rip,%rcx,8) is not — the ModRM/SIB encoding has no room for both. Anywhere the compiler wants base + index*scale against a global, non-PIC code can use the absolute-displacement form and PIC/PIE code cannot. Compiling an array-heavy translation unit both ways shows exactly this and nothing else:
That is the complete diff. Every difference is one of three kinds:
Pattern
Non-PIE
PIE
Real cost
Indexed global arrayarr[i]
movq arr(,%rdi,8), %rax — one instruction
leaq arr(%rip), %rax + movq (%rax,%rdi,8), %rax — two
one extra instruction and one extra register, the only genuine cost
Address of a global into a register
movl $sym, %eax — 5 bytes, absolute R_X86_64_32
leaq sym(%rip), %rax — 7 bytes, PC-relative
2 bytes; same latency, same throughput
Call to an external function
jmp strlen (R_X86_64_PC32)
jmp strlen@PLT (R_X86_64_PLT32)
none — when linked against a shared libc both become the same PLT call
Table: every codegen difference between -fno-pie and -fPIE on x86-64 GCC 16, from a diff of the emitted assembly. The insight to take: the entire cost of PIE on this architecture is one lea in front of indexed accesses to globals — not a reserved register, not a GOT indirection, not a different calling convention.
The relocation histograms make the same point structurally. On a real 262,899-line source file (the SQLite 3.50.4 amalgamation), compiled at -O2:
The R_X86_64_32 and R_X86_64_32S classes — 3,520 absolute 32-bit references — vanish entirely under -fPIE and reappear as PC-relative ones. That, mechanically, is position independence: no absolute references left in the text. The R_X86_64_64 count also drops from 3,404 to 1,447, because absolute 64-bit pointers in static initializers become R_X86_64_RELATIVE entries applied by the loader instead.
Code size: +1.1% on a large real codebase
Build of sqlite3.c (262,899 lines, -O2)
.text size
Δ vs -fno-pie
-fno-pie
906,003 B
—
-fPIE
915,907 B
+1.09%
-fPIC
879,283 B
−2.95%
Table: text-segment size for one large real-world C file compiled three ways on GCC 16.2.1. The insight to take: PIE’s size cost on x86-64 is about one percent, and — counter-intuitively — full PIC came out smaller here than non-PIC.
Uncertain
Verify: why -fPIC produced 2.95% less text than -fno-pie on this input. Reason: the result is reproducible on this machine but the mechanism is not established. A plausible explanation is that PIC’s interposition assumption blocks some interprocedural specialisation and constant propagation that would otherwise duplicate code, so less optimisation yields less text; an equally plausible one is a difference in inlining heuristics. Neither was confirmed. To resolve: rebuild with -fopt-info-inline / -fdump-ipa-all for both modes and diff the inlining decisions, or re-measure with -fno-ipa-cp -fno-ipa-sra to remove the suspected variable. uncertain
Runtime: unmeasurable on the worst-case pattern
If the indexed-array lea is the whole cost, the honest test is a tight loop doing exactly that and nothing else — a 4,096-entry global array indexed by a pseudo-random walk, 400 million iterations. The instructive part is the generated loop:
;; -fno-pie: absolute displacement plus index, one memory operandrun: ... imul $0x41c64e6d,%rax,%rax add $0x3039,%rax and $0xfff,%eax add 0x403040(,%rax,8),%rcx ; absolute base, 8-byte encoding cmp %rdx,%rdi jne ...;; -fPIE -pie: base hoisted OUT of the loop into %rsirun: ... lea 0x2a90(%rip),%rsi ; <-- executed ONCE, before the loop ... imul $0x41c64e6d,%rax,%rax add $0x3039,%rax and $0xfff,%eax add (%rsi,%rax,8),%rcx ; base+index, 4-byte encoding cmp %rdx,%rdi jne ...
The compiler hoists the lea out of the loop — which is the general case for any hot loop — so the loop body is the same instruction count, and the PIE version’s memory operand is actually four bytes shorter. The measurement agrees:
$ for r in 1 2 3 4 5; do printf "nopie "; ./bi_nopie 400000000; printf "pie "; ./bi_pie 400000000; donenopie 1.014 ns/iter pie 1.009 ns/iternopie 1.007 ns/iter pie 1.011 ns/iternopie 1.018 ns/iter pie 1.016 ns/iternopie 1.006 ns/iter pie 1.011 ns/iternopie 1.005 ns/iter pie 1.013 ns/iter
Means: 1.010 ns/iter non-PIE, 1.012 ns/iter PIE — a 0.2% difference against a 1.3% run-to-run spread. There is no signal here. On the single pattern where PIE is supposed to be slowest, on this machine, it is not measurably slower at all, because register allocation hoists the one extra instruction out of anything that runs often.
Why the folklore exists: 32-bit x86 really did pay
The overhead is real on IA-32, and for a specific structural reason that x86-64 does not share: 32-bit x86 has no PC-relative data addressing at all, so PIC code has to materialise the GOT address into a register with a call/pop idiom and then keep it there. Drepper: “the use of %ebx as the PIC register deprives the compiler of one of the precious registers” (Drepper §2) — one of only six usable general registers, permanently reserved. GCC’s own manual still documents this asymmetry in the -fno-plt entry: eliminating PLT stubs “on architectures such as 32-bit x86 where PLT stubs expect the GOT pointer in a specific register … gives more register allocation freedom to the compiler” (GCC, code-gen options) — a sentence that only makes sense for architectures with a dedicated PIC register, which x86-64 is not.
x86-64 removed both halves of that problem at once: it added RIP-relative addressing (so no GOT pointer needs to live in a register) and it doubled the general-purpose register file from 8 to 16 (so losing one would have mattered less anyway). There is no PIC register on x86-64 — visible in every assembly listing above, none of which reserves anything.
Uncertain
Verify: a measured, citable whole-application PIE overhead figure on x86-64 from a published benchmark suite (SPEC CPU or equivalent), and the corresponding figure for 32-bit x86. Reason: the numbers above are this note’s own measurements on one machine — a single microbenchmark, a single large compile — and a microbenchmark cannot stand in for a benchmark suite. Red Hat states only that “performance post process execution is largely comparable to standard Dynamic Shared Objects” (Red Hat), with no numbers; Drepper’s IA-32 discussion is qualitative. Widely repeated figures in the 5–10% range were traced to no primary measurement during this research and are not reproduced here. To resolve: run SPEC CPU 2017 -fno-pie -no-pie versus -fPIE -pie on identical hardware, or locate the distribution’s own benchmark data from the PIE-by-default transitions (Fedora 23, Ubuntu 17.10). uncertain
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). GCC 16’s own manual gives the current, per-architecture limits, and they have moved since Drepper wrote: “If the GOT size for the linked executable exceeds a machine-specific maximum size, you get an error message from the linker indicating that -fpic does not work; in that case, recompile with -fPIC instead. (These maximums are 8k on the SPARC, 28k on AArch64 and 32k on the m68k and RS/6000.) The x86 has no such limit.” (GCC, code-gen options, emphasis added). So on x86 and x86-64 the lowercase/uppercase distinction is entirely vacuous — not merely negligible — and -fpic and -fPIC produce identical code. It matters on AArch64, SPARC, m68k and PowerPC.
The one place the distinction remains observable on x86-64 is the predefined macros, which encode the choice numerically — useful when a header needs to branch on it:
Reading the table: the value distinguishes lowercase (1) from uppercase (2), and the presence of __PIE__ alongside __PIC__ distinguishes executable-targeted from library-targeted code. -fno-pie defines neither, which is the portable way for a header to detect non-PIC compilation. Note that -fPIE implies __PIC__ — PIE code is PIC code with an extra guarantee, so any code path guarded on __PIC__ is correctly taken in a PIE build too.
-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. The header’s e_entry field changes meaning accordingly: in a non-PIE it is a real virtual address, and in a PIE it is an offset that the kernel adds the load bias to (e_entry = elf_ex->e_entry + load_bias; in load_elf_binary()). Measured on the two builds of the same source:
0x490 is plainly not an address anything could execute at — it is 1,168 bytes from the start of the image. That random base the kernel adds is the load bias; because the code is position-independent, adding a bias to every mapping “just works.”
Uncertain
Verify: a statement occasionally repeated about PIEs, that “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.” Reason: an earlier revision of this note attributed that to eklitzke.org, which could not be fetched during this research — curl to https://eklitzke.org/position-independent-executables timed out at both 30 s and 60 s on 2026-09-11, so the quote could not be checked in context. On its face the “hard-coded address” half is wrong for x86-64: e_entry in a PIE is an image-relative offset (0x490 above), and the kernel adds load_bias to it. The claim is retained here only as something to check, not as a cited fact. To resolve: fetch the article when the host is reachable, or discard the citation — the kernel source above already settles the underlying question. uncertainreadelf -h reports a PIE as Type: DYN (Position-Independent Executable file) in recent binutils.
Laid out as a timeline, the whole PIE load path makes the toolchain/kernel boundary explicit — note that every step before execve is compile-and-link time, and every address only becomes real at step 5:
sequenceDiagram
autonumber
participant C as Compiler<br/>(-fPIE)
participant L as Linker<br/>(-pie)
participant K as Kernel<br/>fs/binfmt_elf.c
participant D as ld.so
participant P as Program
Note over C,L: build time — no addresses exist yet
C->>C: emit only PC-relative references<br/>no R_X86_64_32 / 32S in .text
C->>L: relocatable objects
L->>L: produce ET_DYN with PT_INTERP<br/>absolute data pointers -> R_X86_64_RELATIVE<br/>set DF_1_PIE
Note over K,P: run time — execve()
K->>K: PF_RANDOMIZE = personality has no ADDR_NO_RANDOMIZE<br/>AND randomize_va_space != 0
K->>K: load_bias = ELF_ET_DYN_BASE + arch_mmap_rnd()<br/>= 0x555555554000 + (28 random bits << 12)
K->>K: mmap each PT_LOAD at load_bias + p_vaddr<br/>(MAP_FIXED_NOREPLACE)
K->>K: mm->brk = arch_randomize_brk() (18 bits, 1 GiB)
K->>D: map PT_INTERP (independent mmap_base draw)<br/>jump to ld.so entry
D->>D: map DT_NEEDED libraries<br/>apply R_X86_64_RELATIVE: *(base+off) += base
D->>D: resolve GLOB_DAT / JUMP_SLOT<br/>then mprotect the RELRO region
D->>P: jump to e_entry + load_bias
P->>P: _start -> __libc_start_main -> main()
What it shows: the full journey from -fPIE to a running process, with the two authorities separated — the compiler and linker (steps 1–3) commit to position independence without knowing any address, and the kernel (steps 4–8) chooses the addresses without knowing anything about the source. The insight to take: the compiler’s only obligation is “emit nothing absolute”; everything about randomness, entropy and placement is kernel policy applied afterwards to a file that merely permits it.
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). The release notes are precise about the requirements: the resulting libc.a “is usable with GCC 8 and above to create static PIE executables using the GCC option -static-pie”, and the “feature is currently supported on i386, x86_64 and x32 with binutils 2.29 or later, and on aarch64 with binutils 2.30 or later” (glibc 2.27 NEWS, via LWN — fetched and read 2026-09-11).
The mechanism lives in elf/dl-reloc-static-pie.c, which exists only when the library was configured --enable-static-pie (glibc 2.41 source). It is a compressed, single-object version of what ld.so normally does:
void_dl_relocate_static_pie (void){ struct link_map *main_map = _dl_get_dl_main_map (); ... /* Figure out the run-time load address of static PIE. */ ElfW(Addr) l_addr = elf_machine_load_address (); main_map->l_addr = l_addr - file_p_vaddr; /* Read our own dynamic section and fill in the info array. */ main_map->l_ld = ((void *) l_addr + elf_machine_dynamic ()); elf_get_dynamic_info (main_map, false, true); ... /* Relocate ourselves so we can do normal function calls and data access using the global offset table. */ ELF_DYNAMIC_RELOCATE (main_map, NULL, 0, 0, 0); main_map->l_relocated = 1; ...}
Reading it in order: elf_machine_load_address() is an architecture-specific trick that recovers the run-time base without using any relocated data — it has to, because nothing has been relocated yet. l_addr becomes the bias: run-time base minus the link-time p_vaddr of the PT_LOAD at file offset 0 (the source comment notes the subtraction is necessary because “static PIE usually doesn’t have PT_PHDR segment”). elf_machine_dynamic() then locates the object’s own .dynamic, elf_get_dynamic_info parses it, and ELF_DYNAMIC_RELOCATE applies the object’s R_*_RELATIVE (and R_*_IRELATIVE, see GNU IFUNC and Indirect Functions) relocations to itself. The comment on that last call states the bootstrapping problem exactly: only afterwards can the code “do normal function calls and data access using the global offset table.”
This gives 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).
Uncertain
Verify: the runtime behaviour of a -static-pie binary — specifically whether its base gets the same 28 bits of entropy as a dynamic PIE, given that load_elf_binary() routes ET_DYN objects without a PT_INTERP through the load_bias = 0 path (the ordinary mmap base) rather than through ELF_ET_DYN_BASE + arch_mmap_rnd(). Reason: this could not be measured, because Fedora 44 does not ship a static glibc — gcc -static-pie fails with /usr/bin/ld.bfd: cannot find -lc ... have you installed the static version of the c library?. Reading v6.12 fs/binfmt_elf.c, a static PIE takes the else branch, so its randomization comes from arch_pick_mmap_layout rather than from the PIE-specific path. Partially resolved: the section above measures that branch directly using ld.so (also ET_DYN without PT_INTERP) and finds the same 28 bits, in the 0x7f… mmap region rather than the 0x55… region — so a static PIE is expected to behave identically. What remains unverified is that a -static-pie binary in particular hits that branch and is not special-cased elsewhere. To resolve: install glibc-static, build -static-pie, and repeat the 200-run entropy measurement. uncertain
ASLR: The Kernel Half (Linux v6.12)
Everything to this point is toolchain. Where a PIE actually lands, and how unpredictable that is, is decided entirely by fs/binfmt_elf.c and the architecture’s mmap code. This section reads the v6.12 source and then checks each claim against the running system.
Where the kernel decides to put a PIE
The relevant code is in load_elf_binary(). For the first PT_LOAD of an ET_DYN object, the kernel branches on whether the object has a PT_INTERP — i.e. whether it is a program or is itself the loader (Linux v6.12, fs/binfmt_elf.c):
} else if (elf_ex->e_type == ET_DYN) { ... if (interpreter) { /* On ET_DYN with PT_INTERP, we do the ASLR. */ load_bias = ELF_ET_DYN_BASE; if (current->flags & PF_RANDOMIZE) load_bias += arch_mmap_rnd(); /* Adjust alignment as requested. */ if (alignment) load_bias &= ~(alignment - 1); elf_flags |= MAP_FIXED_NOREPLACE; } else { /* * For ET_DYN without PT_INTERP, we rely on * the architectures's (potentially ASLR) mmap * base address (via a load_bias of 0). */ ... load_bias = 0; } ... load_bias = ELF_PAGESTART(load_bias - vaddr);}
Walking it: load_bias is the amount every p_vaddr in the file gets shifted by. For a PIE (an ET_DYNwith an interpreter) it starts at the architecture constant ELF_ET_DYN_BASE and has a random amount added. PF_RANDOMIZE is the per-task flag set earlier (see below) when randomization is enabled. MAP_FIXED_NOREPLACE makes the mapping fail rather than silently relocate if something is already there. The final ELF_PAGESTART(load_bias - vaddr) rounds down to a page and subtracts the first segment’s own p_vaddr so that subsequent per-segment arithmetic is correct.
The kernel’s comment explains the interpreter test at length, and it is worth reading because it answers a question that looks like an inconsistency: why does a PIE get a special base while ld.so itself — also ET_DYN — goes into the ordinary mmap region? Because “to handle invocations of ./ld.so someprog to test out a new version of the loader, the subsequent program that the loader loads must avoid the loader itself, so they cannot share the same load range.”
/* * This is the base location for PIE (ET_DYN with INTERP) loads. On * 64-bit, this is above 4GB to leave the entire 32-bit address * space open for things that want to use the area for 32-bit pointers. */#define ELF_ET_DYN_BASE (mmap_is_ia32() ? 0x000400000UL : \ (DEFAULT_MAP_WINDOW / 3 * 2))
With DEFAULT_MAP_WINDOW = (1UL << 47) - PAGE_SIZE on a 4-level-paging x86-64 kernel, that arithmetic is:
0x555555554000 is where every PIE loads with randomization off — the address every reverse engineer recognises on sight, now derived rather than memorised. Confirming it on the running kernel with setarch -R (which sets the ADDR_NO_RANDOMIZE personality bit):
The text base is 0x555555554000, exactly the computed ELF_PAGESTART(ELF_ET_DYN_BASE). Three consecutive runs produced byte-identical output. Note also that the heap starts immediately after the executable at 0x555555558000: arch_randomize_brk is skipped, so brk sits right at the end of .bss.
static unsigned long arch_rnd(unsigned int rndbits){ if (!(current->flags & PF_RANDOMIZE)) return 0; return (get_random_long() & ((1UL << rndbits) - 1)) << PAGE_SHIFT;}unsigned long arch_mmap_rnd(void){ return arch_rnd(mmap_is_ia32() ? mmap32_rnd_bits : mmap64_rnd_bits);}
Symbol by symbol: get_random_long() draws from the kernel CSPRNG; masking with (1UL << rndbits) - 1 keeps the low rndbits bits; << PAGE_SHIFT scales that to a page-granular byte offset. So the entropy is exactly mmap_rnd_bits bits, at 4 KiB granularity — no sub-page randomization of the executable base is possible, because segments must stay page-aligned. mmap_rnd_bits is the sysctl /proc/sys/vm/mmap_rnd_bits, clamped at build time between CONFIG_ARCH_MMAP_RND_BITS_MIN and _MAX. On the test machine’s kernel config those are 28 and 32, with the default 28.
The same arch_mmap_rnd() value is also what mmap_base() uses to place the top of the mmap region, which is why shared libraries get the same entropy from the same draw — but a different draw from the executable’s, since arch_mmap_rnd() is called separately.
Measuring the entropy instead of trusting the constant
200 runs of the same PIE, recording the address of main:
$ for i in $(seq 200); do ./app_pie; done | awk '/^main/{print $3}' > pie_main.txt$ python3 - <<'PY'import mathvals=[int(l,16) for l in open('pie_main.txt')]print("distinct values:", len(set(vals)), "of", len(vals))print("page offsets seen:", {hex(v & 0xfff) for v in vals})pages={v>>12 for v in vals}print("page-number span: 2^%.1f" % math.log2(max(pages)-min(pages)+1))PYdistinct values: 200 of 200page offsets seen: {'0x3f0'}page-number span: 2^28.0
Three facts confirmed at once: all 200 bases were distinct (no obvious collisions), the offset within the page is constant at 0x3f0 (confirming page-granular randomization, exactly as << PAGE_SHIFT implies), and the span of page numbers is 2²⁸ — matching CONFIG_ARCH_MMAP_RND_BITS=28 to the bit.
Doing the same for every region, PIE versus non-PIE:
Table: measured ASLR entropy per region, 200 executions each, Fedora 44 / kernel 7.1.13 / randomize_va_space=2. The insight to take: PIE buys 28 bits for the two rows that would otherwise have zero — the executable’s own code and data. Every other region is randomized identically whether or not the binary is a PIE, which is why “we have ASLR, so PIE adds nothing” is precisely backwards: the executable is the one thing ASLR cannot cover without PIE.
unsigned long arch_randomize_brk(struct mm_struct *mm){ if (mmap_is_ia32()) return randomize_page(mm->brk, SZ_32M); return randomize_page(mm->brk, SZ_1G); /* 64-bit: 1 GiB window */}unsigned long randomize_page(unsigned long start, unsigned long range){ ... range >>= PAGE_SHIFT; /* 1 GiB / 4 KiB = 2^18 */ if (range == 0) return start; return start + (get_random_long() % range << PAGE_SHIFT);}
1 GiB divided by the 4 KiB page size is 2¹⁸ possible placements — 18 bits. Measured, from 200 runs reading [heap] out of /proc/self/maps:
distinct 200 min 0x638000 max 0x3fd63000 span 0x3f72b000 (1015.2 MiB)bits at page granularity: 17.99
1,015.2 MiB against a 1,024 MiB window, 17.99 bits against 18 — the sampling shortfall you would expect from 200 draws. The stack’s mask comes from arch/x86/include/asm/elf.h:
/* 1GB for 64bit, 8MB for 32bit */#define __STACK_RND_MASK(is32bit) ((is32bit) ? 0x7ff : 0x3fffff)
0x3fffff is 22 bits, shifted by PAGE_SHIFT for a 1 GiB (2³⁰-byte) window — but the observed span was about 2³⁴ bytes, four times wider than the comment suggests, because the stack pointer within the top page also varies with the environment and argument block. The 22 bits are what an attacker aiming at a page can rely on; the extra low bits are not independently controllable.
Uncertain
Verify: the exact decomposition of the ~2³⁴-byte observed stack-address span into STACK_RND_MASK page bits plus sub-page variation, and whether the kernel comment “1GB for 64bit” is stale relative to the code. Reason: 0x3fffff << 12 is exactly 2³⁴ bytes = 16 GiB, not the 1 GiB the adjacent comment claims, so either the comment predates a widening of the mask or it refers to something else. This note reports the measurement (2³⁴-byte span) and the constant (0x3fffff), which agree with each other, and flags the comment as the odd one out. To resolve: git log -p the __STACK_RND_MASK definition, and instrument randomize_stack_top(). uncertain
randomize_va_space — the one global switch
Randomization is gated by a per-task flag set once per execve, from load_elf_binary():
Two gates, both of which must pass: the process must not carry the ADDR_NO_RANDOMIZE personality bit (what setarch -R sets, and what a debugger sets for you), and the global sysctl must be non-zero. The READ_ONCE into a local is deliberate — the sysctl is sampled once at execve so that a concurrent change cannot make the same load use inconsistent policy halfway through. The documented values are (Linux v6.12, Documentation/admin-guide/sysctl/kernel.rst):
randomize_va_space
What is randomized
What is not
When it is the default
0
nothing
everything — mmap base, stack, vDSO, PIE base, heap
architectures without ASLR support, or boot with norandmaps
1
“mmap base, stack and VDSO page”; “for PIE-linked binaries, the location of code start is randomized”
the heap (brk)
when CONFIG_COMPAT_BRK is enabled
2
all of the above plus heap randomization
—
when CONFIG_COMPAT_BRK is disabled — the case on essentially every modern distribution
Table: the three randomize_va_space settings, quoted from the kernel’s own sysctl documentation. The insight to take: the only difference between 1 and 2 is the heap, and the reason 1 exists at all is backward compatibility with “some ancient versions of libc.so.5 from 1996” that assumed brk starts immediately after .bss — the kernel documentation says so in as many words, and notes there are “no known non-legacy applications that would be broken this way.”
The test machine has randomize_va_space = 2 and # CONFIG_COMPAT_BRK is not set, matching the documented pairing exactly.
What it shows: the same five address-space regions under a non-PIE and a PIE, coloured red for deterministic and green for randomized, with the measured entropy of each. The insight to take: exactly one region differs, and it is the one containing the program’s own code — the gadgets an attacker most wants a fixed address for. Everything else was already randomized.
Confirming the other branch: an ET_DYN with no PT_INTERP
The else branch of that code — ET_DYN objects that are not PIEs, i.e. loaders — is testable without writing anything, because ld.so is exactly such an object and is directly executable:
Running it directly (ld.so /usr/bin/cat /proc/self/maps) 100 times and recording where the kernel placed ld.so itself, and separately where it placed the heap:
ld.so base (ET_DYN, no PT_INTERP) n=100 distinct=100 0x7eff22636000 .. 0x7ffbe59ac000 span 2^28 pagesbrk when exec is ET_DYN w/o INTERP n=100 distinct=100 0x555555708000 .. 0x555594f08000 span 2^18 pagesELF_ET_DYN_BASE 0x555555554000
Both halves of the source match. ld.so landed in the 0x7f…mmap region — the load_bias = 0 path, 28 bits from arch_pick_mmap_layout’s own arch_mmap_rnd() draw, not from ELF_ET_DYN_BASE. And its heap landed just above ELF_ET_DYN_BASE with an 18-bit spread, which is the other half of the special case read earlier:
The comment above it explains why: when the executable is itself the loader, the brk “grows up, and may collide early with the stack growing down”, so the kernel moves it “into the unusedELF_ET_DYN_BASE region” — unused precisely because no PIE was placed there in this scenario. Measured 0x555555708000 is ELF_ET_DYN_BASE + 0x1b4000, and 0x555594f08000 is ELF_ET_DYN_BASE + 0x3f9b4000 (1,017 MiB) — the 1 GiB randomize_page(mm->brk, SZ_1G) window landing exactly where the code says it will.
The practical upshot is a small trap: when you run a program by invoking the loader explicitly (ld.so ./prog, a common debugging move), the program is no longer placed at ELF_ET_DYN_BASE — it gets mapped by ld.so into the ordinary mmap region alongside the libraries, as 0x7f038a9e6000 in one run here. Address patterns learned from normal execution do not carry over.
The executable and the libraries are randomized independently
A subtlety worth confirming rather than assuming: the executable’s base and the shared-library region’s base come from two separate arch_mmap_rnd() draws, so leaking one does not reveal the other. Testing for correlation across the 200-run sample:
n=200 Pearson r(exec base, libc base) = -0.0912exec base range: 0x555666591000 .. 0x5653bf1ed000libc base range: 0x7efc5b770000 .. 0x7ffbf461d000
An r of −0.09 over 200 samples is no correlation, and the two ranges do not even overlap — the executable lives in the 0x55… region seeded from ELF_ET_DYN_BASE, the libraries in the top-down 0x7f… mmap region. This separation is the fix for the historical offset2lib weakness, in which the executable was mapped adjacent to the libraries so that a single leaked library address gave away the program’s base for free. The kernel comment quoted earlier — “programs are loaded offset from ELF_ET_DYN_BASE and loaders are loaded into the independently randomized mmap region” — is the code that keeps them apart.
Code and Specification
Building the three variants and inspecting the ELF type:
A ten-line program that prints the addresses of its own code, data, stack and heap settles the question in one command. The only subtlety is choosing what to print: main for the text segment, the address of a global for the data segment, printf for the shared-library region, the address of a local for the stack, and the return of malloc(16) for the heap.
Read those two blocks side by side and three things stand out:
main and &global are constant across every non-PIE run and never repeat across PIE runs. That is the entire security difference, visible in two columns.
stack and heap are randomized in both. ASLR was covering those long before PIE existed; the non-PIE heap moves over a 1 GiB window (the SZ_1G derivation above) while the PIE heap follows the randomized executable.
The printf column differs in kind, not just in value. In the PIE build it prints 0x7f…, an address inside the mapped libc. In the non-PIE build it prints 0x400380 — an address inside the executable, because taking the address of an imported function in a non-PIE yields the local PLT stub (the “canonical PLT entry”), not the real function. This is the same mechanism behind Copy Relocations, and it is a trap when comparing address leaks between the two builds: the non-PIE value is not the libc address at all.
Turning randomization off with setarch -R, which sets the ADDR_NO_RANDOMIZE personality bit that load_elf_binary() checks:
Every column is now identical run to run, in both builds — setarch -R (util-linux 2.41.5 here) is the switch that makes a PIE behave, address-wise, exactly like a non-PIE. The PIE’s 0x555555554000 text base is the ELF_ET_DYN_BASE constant derived earlier; the stack top 0x7fffffffc3fc and the libc base 0x7ffff7… are the un-randomized STACK_TOP and mmap_base. This is why setarch -R is the right tool for reproducing an address-dependent bug, and equally why a setarch -R reproduction tells you nothing about whether the bug is exploitable in production.
setarch -R disables ASLR; it does not disable PIE
A PIE run under setarch -R is still ET_DYN, still position-independent code, still relocated by ld.so. Only the choice of base becomes deterministic. Conversely, a non-PIE gets no executable-base randomization even with randomize_va_space = 2. The two knobs are independent and both must be on for the mitigation to exist — which is exactly why GDB, which disables randomization by default, makes PIE binaries look deceptively predictable during debugging.
Checking 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-independent
The 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), %eax
The 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 were then expected to opt in, and the change proposals say so: Fedora 28’s hardening change states that Fedora would “use --enable-default-pie to ensure PIE support is enabled across all architectures” (Fedora HardeningFlags28). That is the plan, not the artifact — see the correction immediately below, which measures what Fedora 44 actually does. One GCC manual page is often cited here and should not be: “Position Independent Executable (PIE) Enabled by Default on Linux” is a section of the GNAT User’s Guide — the Ada front end — and says something narrower than it is usually quoted for. Read in full, it states “GNAT generates Position Independent Executable (PIE) code by default”, explains that “building PIE binaries requires that all of their dependencies also be built as Position Independent”, and tells you how to opt out (GNAT User’s Guide, GNU/Linux Topics, fetched 2026-09-11). It is about Ada, it makes no claim about the C compiler, and it does not mention --enable-default-pie or packaging at all.
What that page is genuinely useful for is the canonical error you hit when one object in a link was not built PIC:
/[...]/ld: /path/to/object/file: relocation R_X86_64_32S against symbol`symbol name' can not be used when making a PIE object;recompile with -fPIE
That message is the linker’s direct statement of the whole PIE contract: an R_X86_64_32S is a signed absolute 32-bit reference, and an absolute reference cannot survive a runtime load bias. The 3,520 such relocations that disappeared from the SQLite build under -fPIE (measured earlier) are exactly the ones this diagnostic is about. The fix is always the same — rebuild the offending object with -fPIE (for something going into an executable) or -fPIC (for something going into a library) — and the common cause is a prebuilt vendor .a, a hand-written assembly file, or a Makefile that appends to CFLAGS for its own sources but links in a third-party object it did not compile.
Reproducing it takes two commands. d_nopie.o below is the array-heavy translation unit from earlier, compiled -fno-pie; mainuse.o is a -fPIE object that calls into it:
$ gcc -pie -o bad_pie mainuse.o d_nopie.o/usr/bin/ld.bfd: d_nopie.o: relocation R_X86_64_32S against `.rodata' can not be used when making a PIE object; recompile with -fPIE/usr/bin/ld.bfd: failed to set dynamic section sizes: bad valuecollect2: error: ld returned 1 exit status$ gcc -no-pie -o ok_exec mainuse.o d_nopie.o$ echo $?0
The second command proves the object itself is fine — it is only incompatible with a PIE link. That asymmetry is why the failure so often appears as “our build broke when the distro turned PIE on by default” rather than as an obvious bug in the offending code. Note also which section the linker names: .rodata, not a function — the absolute reference was in the names[] string-pointer table, the exact construct the codegen diff earlier showed changing form under -fPIE.
The practical consequence: whether gcc hello.c produces a PIE or an ET_EXEC depends entirely on which GCC you invoke and how you invoke it. Never state “GCC defaults to PIE” as a universal fact — it is exactly parallel to the full-RELRO/-z now situation where Fedora/Debian defaults are distro policy layered on top of upstream tools.
Correction, measured 2026-09-11: Fedora does not use --enable-default-pie
The paragraph above followed the Fedora 28 change proposal, which does say “Use --enable-default-pie to ensure PIE support is enabled across all architectures” (Fedora HardeningFlags28). Whatever the proposal intended, that is not what Fedora 44 ships, and the difference is easy to check. Fedora’s GCC 16.2.1 reports its own configure line, and --enable-default-pie is not in it:
A bare gcc on stock Fedora 44 produces a non-PIE ET_EXEC (with partial RELRO — -z relrois the linker default, -z now is not). The same conclusion from the packaging side: Fedora’s own gcc.spec for this exact build (%global DATE 20260819, matching the installed gcc 16.2.1 20260819) contains no --enable-default-pie anywhere, and the one PIE-adjacent configure option it does carry is gated to RHEL only (Fedora gcc.spec, rawhide):
That %if also explains a curiosity in the fleet: /usr/bin/gcc on Fedora is itself a non-PIE, partial-RELRO binary, because --enable-host-pie (which hardens the compiler’s own executables) is applied on RHEL and not on Fedora.
So the correct statement for Fedora is: PIE comes from the RPM build macros, not from the compiler’s configuration. The two files that do it are on every Fedora system:
These are GCC spec files, pulled in by %_hardening_gcc_cflags / %_hardening_gcc_ldflags in redhat-rpm-config-344-2.fc44. Read the nested conditionals: -fPIE is appended only if none of -r, -fpie, -fPIE, -fpic, -fPIC, -fno-pic was already given — so an explicit choice always wins — and -pie is appended only when linking something that is neither -static nor -shared nor a relocatable partial link. That is a careful, defensive default, and it is invisible to anyone not building through rpmbuild.
The real per-distribution matrix, as of 2026-09-11
Table: where PIE actually comes from on four distributions. The insight to take: there are two structurally different mechanisms in play — a patched compiler (Ubuntu) versus injected build flags (Fedora, Arch) — and only the first one follows you outside the package build. On a Fedora box, gcc hello.c gives you ET_EXEC; on an Ubuntu box it gives you a PIE. “The distro builds PIE” is true of both and means different things.
Ubuntu’s matrix is also the best available answer on architecture coverage, and it is explicitly partial: “Built as PIE” and “Built with BIND_NOW” are each annotated “gcc patch (amd64, ppc64el, s390x), package list for others”, while “Built with RELRO” is annotated “gcc patch” with no architecture qualifier (Ubuntu wiki, Security/Features). So RELRO is universal there and PIE/BIND_NOW are not — which is the concrete shape of the “per-arch defaults differ” warning, from the distribution’s own documentation.
An empirical cross-check on the Fedora side: scanning every ELF binary in /usr/bin on the test machine (2,875 of them) found 2,785 ET_DYN (96.9%) and 90 ET_EXEC (3.1%). The non-PIE remainder is dominated by gcc and friends, wine, virtualbox-guest-additions, and Go-toolchain binaries — the same set that shows up as partial-RELRO in RELRO Relocation Read-Only, because they are the packages whose build systems discard or override the injected flags.
Uncertain
Verify: whether GCC’s --enable-default-pie remains the mechanism any current mainstream distribution uses, and the PIE default on non-x86-64 Fedora/Debian architectures. Reason: this note now has direct evidence that Fedora 44 does not use it and that Ubuntu achieves the same end through a patched compiler; Arch’s makepkg.conf sets flags rather than configuring GCC. That leaves the configure option documented by GCC but with no confirmed current user among the distributions checked here — possible, but not established. Architecture coverage rests on Ubuntu’s own qualifier (“amd64, ppc64el, s390x”) and was not independently measured. To resolve: inspect the gcc package sources for Debian, Ubuntu, Arch and openSUSE for --enable-default-pie, and repeat the /usr/bin ELF-type scan on an aarch64 install. 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. The two conditions are the two gates in load_elf_binary() quoted above, and the full truth table is small enough to write out:
Binary type
randomize_va_space
ADDR_NO_RANDOMIZE personality
Executable base
Measured
PIE (ET_DYN + PT_INTERP)
2 (or 1)
clear
random, 28 bits
200/200 distinct
PIE
2
set (setarch -R, GDB default)
fixed 0x555555554000
3/3 identical
PIE
0
clear
fixed 0x555555554000
derived — PF_RANDOMIZE never set, arch_mmap_rnd() returns 0
non-PIE (ET_EXEC)
2
clear
fixed 0x400000
200/200 identical
non-PIE
0
either
fixed 0x400000
—
Table: when a PIE actually gets a random base. The insight to take: three independent things must all hold — the binary must be ET_DYN, the sysctl must be non-zero, and the process must not carry ADDR_NO_RANDOMIZE. Two of the three are runtime properties the build cannot control, which is why “we ship PIE binaries” is a necessary but not sufficient claim about a deployment.
Uncertain
Verify: the randomize_va_space = 0 rows of that table by measurement rather than derivation. Reason: writing /proc/sys/kernel/randomize_va_space requires root, which was not available on the test machine; those two rows are read off the if (!(current->personality & ADDR_NO_RANDOMIZE) && snapshot_randomize_va_space) condition in v6.12 fs/binfmt_elf.c rather than observed. The setarch -R rows were measured and exercise the same PF_RANDOMIZE flag through the other gate, so the derivation is well-supported but not independent. To resolve: as root, sysctl kernel.randomize_va_space=0 and re-run the 200-execution sampler. uncertain
GDB and perf silently turn ASLR off. A practical corollary of the row above: gdb sets ADDR_NO_RANDOMIZE by default (set disable-randomization on), so a PIE debugged interactively always loads at 0x555555554000. Addresses learned in a debugging session are therefore not the addresses the program sees in production, and a heap-spray or offset calculation validated under GDB will not transfer. set disable-randomization off restores randomization — at the cost of making breakpoint addresses move between runs.
Startup cost is real but small on x86-64 — and here is how small. A PIE has more RELATIVE relocations than a non-PIE, so it does marginally more work at startup. Building the SQLite 3.50.4 command-line shell (shell.c + sqlite3.c, ~1.5 MB of binary) three ways quantifies it:
Build
R_X86_64_RELATIVE relocs
Relocation table size
Binary size
Startup (1,000 execs, 3 reps)
-fno-pie -no-pie
0
RELASZ 648 B
1,491,224 B
526 / 543 / 530 µs
-fPIE -pie
2,020
RELASZ49,248 B
1,548,680 B
547 / 527 / 548 µs
-fPIE -pie -Wl,-z,pack-relative-relocs
0 (moved to DT_RELR)
RELASZ 768 B + RELRSZ 488 B
1,499,600 B
522 / 544 / 555 µs
Table: the real startup cost of PIE on a real program. The insight to take: 2,020 extra relocations are invisible — all three builds land in the same 522–555 µs band with the spread dominated by run-to-run noise. The cost PIE actually imposes is on-disk size, and DT_RELR erases even that.
The middle row is where the folklore is grounded: a PIE really does carry a 48 KiB relocation table where the non-PIE carries 648 bytes, because every absolute pointer in a static initializer becomes an R_X86_64_RELATIVE entry (24 bytes each: offset, info, addend). The third row is the fix. DT_RELR is a bitmap encoding for runs of adjacent relative relocations — instead of 24 bytes per relocation it spends roughly one bit per eligible word — and it compresses those 48,600 bytes into 488 bytes, a ~100× reduction that also shrinks the binary by 49 KB (3.2%). Enabling it costs one linker flag, -Wl,-z,pack-relative-relocs, documented as adding “DT_RELR, DT_RELRSZ and DT_RELRENT entries to the dynamic section”; it “is ignored when building position-dependent executable” and, “when linked against the GNU C Library, a GLIBC_ABI_DT_RELR symbol version dependency on the shared C Library is added” (binutils, ld options). That last clause is the catch: a DT_RELR binary will not run against a glibc too old to know the format, and the version dependency is what makes that failure a clean startup error rather than a crash. nopack-relative-relocs is the binutils default, so this is opt-in upstream — but Fedora turns it on for the four architectures where it is supported:
Do not conflate any of this with the steady-state cost, which RIP-relative addressing makes essentially zero on x86-64 (measured above at 0.2% against 1.3% noise).
Alternatives and When to Choose Them
The flag choice is genuinely mechanical once you know what the output is and whether it can be interposed:
flowchart TB
Q0{"What are you building?"}
Q0 -->|"shared library (.so)"| PIC["<b>-fPIC</b> + -shared<br/>must be PIC — a .so has no fixed base<br/>every exported symbol may be interposed<br/>-fpic identical on x86, differs on AArch64/SPARC"]
Q0 -->|"executable"| Q1{"Is a randomized base<br/>worth ~1% text size?"}
Q1 -->|"yes (default answer)"| Q2{"Dynamically linked?"}
Q1 -->|"no — 32-bit, or a<br/>hard fixed-address requirement"| NOPIE["<b>-fno-pie -no-pie</b><br/>ET_EXEC, 0 bits on text and data<br/>only defensible with a stated reason"]
Q2 -->|"yes"| PIE["<b>-fPIE -pie</b><br/>ET_DYN + PT_INTERP<br/>28 bits from ELF_ET_DYN_BASE + arch_mmap_rnd()<br/>add -Wl,-z,relro,-z,now and -z pack-relative-relocs"]
Q2 -->|"no — single self-contained binary"| SPIE["<b>-static-pie</b><br/>ET_DYN, no PT_INTERP<br/>self-relocates via _dl_relocate_static_pie<br/>needs glibc built --enable-static-pie"]
classDef rec fill:#d3ecd5,stroke:#282,color:#000
classDef warn fill:#f9d5d3,stroke:#b23,color:#000
class PIE,PIC,SPIE rec
class NOPIE warn
What it shows: the decision tree for -fPIC / -fPIE / -fno-pie / -static-pie, with the consequence of each written into the node. The insight to take: only one question is a real trade-off (Q1), and on x86-64 the measurements in this note put that trade-off at about 1% of text size and no measurable runtime cost — so the honest default is PIE everywhere, with non-PIE needing a justification rather than the other way round.
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 runtime ld.so dependency 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 vault finding, restated with the correction established above: Fedora and peers ship PIE + full RELRO + BIND_NOW as distribution policy, implemented on Fedora through RPM-injected GCC spec files (redhat-rpm-config’s redhat-hardened-cc1 and redhat-hardened-ld) — not through --enable-default-pie, and 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, or simply gcc hello.c in a shell on the same Fedora box — you get ET_EXEC with partial RELRO unless you pass the flags yourself. That last case is the one people miss: the hardening does not come from the compiler you are holding, it comes from the build system you are not using.
Measured on the test machine, the gap between policy and artifact is about 3%: of 2,875 ELF binaries in /usr/bin, 2,785 are DYN and 90 are EXEC; 2,774 are full RELRO, 96 partial, 5 none. The full breakdown and the list of offending packages is in RELRO Relocation Read-Only.
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.