Back to course

The Flags, and Which One Is Which Kind

19 min · Module 2: Position Independent Executables · Intermediate

Why This Matters

There are four flags, they all contain “pie” or “pic”, and they do not all mean the same kind of thing. Here is the complete matrix, every row built and measured:

$ for spec in "-fno-pic -fno-pie -no-pie" \
                "-fno-pic -fno-pie -pie" \
                "-fPIE -pie" \
                "-fPIE -no-pie" \
                "-fPIC -shared"; do
    fl="${spec%%:*}"; n="${spec#*:}"
    clang -O1 $fl -o p_$n tiny.c
    printf "  %-9s %-24s %s\n" "$n" "$fl" "$(readelf -hW p_$n|awk '/Type:/{print $2}')"
  done

  nopie     -fno-pic -fno-pie -no-pie   EXEC
  halfpie   -fno-pic -fno-pie -pie      DYN
  pie       -fPIE -pie                  DYN
  halfpie2  -fPIE -no-pie               EXEC
  picso     -fPIC -shared              DYN

Read rows two and four. They are the interesting ones, because in each the two flags disagree and the result is a binary that is neither thing:

-f and -f ARE THE SAME KIND OF THING. both change what the COMPILER emits. they are codegen. -pie and -no-pie ARE THE SAME KIND OF THING. both change what the LINKER produces. they are link decisions, and they set e_type. A ROW IS COHERENT when the two agree about the answer to "will this be moved?". halfpie: code says NO, file says YES halfpie2: code says YES, file says NO

Neither half-row is wrong, exactly. Both are build configurations someone can create by accident, and both work. But each one pays a cost without receiving the benefit, and that asymmetry is the whole subject of this concept.

A Simple Model

Two independent questions, and it is worth being precise that they are asked at different times by different programs.

Q1 COULD THIS CODE SURVIVE BEING MOVED? asked by the COMPILER, at -c time answered by: which relocation types it emits cost of answering YES: an extra indirection per reference (measured in pie-cost) Q2 WILL THIS FILE ACTUALLY BE MOVED? asked by the LINKER, at link time answered by: e_type = ET_DYN or ET_EXEC cost of answering YES: none at run time, but the loader must honour a random base Q1 and Q2 are the same question asked by two programs that cannot see each other. Nothing enforces agreement.

That last line is the whole concept. There is no mechanism by which clang -fPIE can check what ld -no-pie will do, and no mechanism by which the linker can check what the compiler emitted. They are separate processes making a correlated decision, and the correlation is maintained by a build system.

Which brings up the two defaults, which are not the same on every distribution:

  $ clang --version | head -1
  Ubuntu clang version 21.1.8

  $ clang -### -c tiny.c 2>&1 | grep -o 'fPIE\|fPIC\|fno-pie' | sort -u
  (this machine defaults to -fPIE for executables)

  $ clang -### -shared tiny.c 2>&1 | grep -o 'fPIC'
  (and to -fPIC for shared objects)

So on a modern distribution the compiler default is already position-independent, and -no-pie is a deliberate step backwards. That reverses the usual intuition, where PIE feels like something you opt into. Here it is the default and opting out is the unusual act.

Does It Actually Work?

Now the cost of the two incoherent rows, measured rather than asserted. Take the case the symbol-resolution course found and put a number on it:

$ cat owner.c
extern int lib_data;
int main(void){ return lib_data ? 1 : 0; }

$ clang -O1 -fPIE   -no-pie -o halfpie2 owner.c -L. -lsym
$ readelf -rW halfpie2 | grep lib_data
  ... R_X86_64_GLOB_DAT   lib_data + 0      <-- still going through the GOT

$ readelf -hW halfpie2 | awk '/Type:/{print "   and e_type is", $2}'
   and e_type is EXEC

An ET_EXEC binary still carrying a GLOB_DAT. The address of lib_data is fixed at link time — the file says so — but the code was compiled to load that address from a GOT slot at runtime. The indirection is pure waste: the loader patches a slot whose value the linker already knew.

And the reverse case, which costs an instruction on every single call:

$ clang -O1 -fno-pic -fno-pie -pie -o halfpie tiny.c
$ readelf -hW halfpie | awk '/Type:/{print "   e_type is", $2}'
   e_type is DYN
$ readelf -rW halfpie | grep -c PLT32
   1

A DYN file whose code was compiled assuming a fixed layout. If the loader honours the random base the code expects, the program must not be moved — and on a system that does move ET_DYN files, the only thing keeping it working is that the loader happened to place it where the code assumed, which is a property of the default base, not a guarantee.

That is the dangerous half-row, and the reason it deserves a warning. A -fno-pic object in a DYN file is a latent crash that works on your machine because your loader picks a conventional base address, and fails on a system where it does not. The next concept measures whether a coherent PIE actually moves; this is the case where one does not and should not.

A Real Example

The mistake that actually happens, because it does not look like a mistake. A build system wants non-PIE binaries for compatibility with an old assumption, and writes:

  # WRONG: fixes the link, not the code
  CFLAGS  += -fno-pie
  LDFLAGS += -no-pie
  # -> works, but the -fno-pie does nothing you wanted

  # RIGHT
  CFLAGS  += -fno-pie        # codegen: fixed-layout code
  LDFLAGS += -no-pie         # link:    ET_EXEC

  # ALSO RIGHT, and better
  CFLAGS  += -fno-pic -fno-pie
  LDFLAGS += -no-pie

The first version is the common one and it is not harmless, because -fno-pie on its own does not switch off PIC codegen — it switches off PIE codegen, and on this toolchain the default was PIE anyway, so the flag is very nearly a no-op. The binary ends up ET_EXEC with PIE-style GOT indirection. You asked for less position independence and got none of the benefit, because the flag you reached for is the wrong one.

The distinction that resolves it is a single question: does the flag change the bytes in the .o, or the bytes in the executable? Everything prefixed -f changes the object. Everything prefixed -no/-shared/-pie passed to the linker changes the executable. If you can answer that for a flag you are using, you can predict what it does.

One more trap, and it is the one the symbol course found the hard way. The reverse — -fPIE compiled, -no-pie linked — produced the GLOB_DAT instead of the COPY that a genuinely non-PIE executable gets, because the code model decided the access sequence before the link model got a vote. One flag changed which relocation the loader is asked to process. That is the clearest possible demonstration that Q1 and Q2 are not merely separate but consequential: the code model picks the relocation, and the relocation decides how much work the loader does.

Try It Yourself

$ cd courses/reloc/assets/samples
$ ./build_samples.sh 2>&1 | sed -n '/F3/,/F4/p'
$ python3 crosscheck.py 2>&1 | sed -n '/\[3\]/,/\[4\]/p'

Then build the matrix yourself and predict each cell before you run it:

  codegen \ link      -no-pie              -pie              -shared

  -fno-pic -fno-pie     EXEC, no GOT        ???                ???
  -fPIE                EXEC, GOT (measured) ???                ???
  -fPIC                ???                 ???                ???

  1. Fill in the nine cells: e_type, and whether a data
     reference goes through the GOT.

  2. Which of the nine are COHERENT, and what is the rule
     that decides coherence?

  3. Take the coherent -fPIE/-no-pie cell and the coherent
     -fno-pic/-no-pie cell. Which one is faster, and by
     roughly how much per data reference? (The next
     concept measures it; predict first.)

  4. Find a flag whose NAME suggests one kind and whose
     BEHAVIOUR is the other. (-fPIE vs -fPIC is the
     obvious one. Is there a worse one?)

Question 2 has the rule, and it is one sentence: a row is coherent when the code model and the file type agree on whether the image can move. Four of the nine cells are coherent, one is impossible (-fno-pic into -shared is the PIC violation), and the remaining four are the incoherent configurations this concept is about. All four are constructible, all four run, and all four are mistakes.

Connect

This opens the PIE module, and the connection back is the direct answer to a question the vocabulary module left open. Why There Are So Many Kinds grouped the relocations and noted that PIE and PIC emit the same vocabulary for the same source — and said the difference was in what the loader may do with it. This concept is that difference, in the form of two flags that different processes read.

Within the module the chain is short and load-bearing. This concept establishes which binary you have. Does the Executable Actually Move? then checks the only question that matters about it, with six runs of each. What Position Independence Costs puts a number on the answer, in instructions and bytes, so that “should I?” has a defensible answer rather than a slogan.

The strongest connection is back to the symbol course, and it is a mechanism rather than a theme. Who Owns the Storage found that R_X86_64_COPY versus R_X86_64_GLOB_DAT is decided by the code model of the executable’s translation unit, and that -no-pie at link time alone is not enough. This concept explains why that was surprising: the answer was being chosen by a flag three compilation steps away, and the flag that “sounds” like the right one does not make the decision. Two courses, one mechanism, and the reason it is worth learning twice is that the failure is silent in both cases — a working binary with a cost you did not ask for, or a working binary that will break on a machine that is not yours.

Forward into the failure module, the incoherent -fno-pic-into--shared cell is the one cell the flag matrix cannot legalise, and The Relocation That Cannot Be Fixed is about what happens there. The distinction to carry forward is between a configuration that is incoherent but legal — the four cells above, all of which link and all of which run — and one that is incoherent and rejected. Only the second produces a diagnostic, and a course that teaches only the second leaves you unprotected against the first.