Underlayer
LearnPathReviewProgressSearch
Your space
DashboardBookmarksNotesStudy plannerAchievementsStreaksAlertsCertificates
HelpFAQSettings
Sign in to keep your progress across lessons.Sign inor create an account
Concept 9 of 10 in Static, and Build OnePrevious: The Sections the Script ForgotNext: Writing One Yourself
Claim your certificate

Sign in to bookmark this lesson, keep notes on it, and send corrections.

    Back to course

    What -static Actually Does

    ⏱20 min▲intermediate☰Module 4: Static, and Build OneContinues from link-orphans
    20 min · Module 4: Static, and Build One · Intermediate

    Why This Matters

    “Static linking” is three different things wearing one name, and the name is why most explanations of it are unsatisfying. The program to measure is one line:

    $ printf 'int main(void){ return 0; }\n' > tiny.c
    $ clang -O1          -o s_dyn   tiny.c
    $ clang -O1 -static  -o s_static tiny.c
    $ for f in s_dyn s_static; do
        printf "  %-9s size=%-8s e_type=%-5s PT_INTERP=%s  PT_LOADs=%s\n" $f \
          "$(stat -c%s $f)" \
          "$(readelf -hW $f|sed -n 's/.*Type: *\([A-Z]*\).*/\1/p')" \
          "$(readelf -lW $f|grep -c INTERP)" \
          "$(readelf -lW $f|grep -c '^  LOAD')"
      done
    
      s_dyn     size=15880   e_type=DYN   PT_INTERP=1  PT_LOADs=4
      s_static  size=825160  e_type=EXEC  PT_INTERP=0  PT_LOADs=4
    

    52 times bigger, and the e_type changed. Those are the two headline facts and they are not independent — and the connection between them runs straight through a linker script, which is why this concept is in this course.

    A Simple Model

    Three distinct meanings, and only one of them is usually meant.

    1. STATIC ARCHIVES, DYNAMICALLY LINKED ld main.o libfoo.a -lc your code is in the binary; libc is not. THIS IS THE DEFAULT and is not what people mean. 2. STATIC libc -static: no .so, no ld.so, one binary. the C library's code is IN the file. 3. FULLY STATIC -static plus no PT_INTERP, so there is no dynamic loader in the picture at all. the kernel maps the file and jumps to e_entry. THE DIFFERENCE BETWEEN 2 AND 3 IS A PROGRAM HEADER you did not write: PT_INTERP. it names the file to load as the interpreter -- /lib64/ld-linux-x86-64.so.2 on this machine. -static removes it, and that is most of what "static" means mechanically.

    So the flag does two separable things: it stops recording references to libc.so and pulling in libc’s code, and it stops emitting PT_INTERP. The second is what makes the binary runnable by the kernel alone. A file can be in an odd middle state — statically linked against libc but still with an interpreter — and that is worth knowing exists, because it is how you get an error about a missing dynamic loader on a system that obviously has one.

    WHY THE SIZE EXPLODES -- and it is not one thing .text 0x08497d = 543 KB libc's CODE .rodata 0x01c57c = 116 KB libc's strings, tables, locale data .eh_frame 0x00966c = 38 KB unwinding tables for all of the above .symtab 0x00c210 = 49 KB symbols for all of it vs the dynamic build: .symtab 0x000348 = 840 bytes .eh_frame under 4 KB the dynamic binary has 840 bytes of symbols because its code is YOUR code. the static one has 49 KB because it now contains a C library.

    And here is the link to this course: that .text is at 0x400000 because of a line in the script.

    Does It Actually Work?

    The segments, side by side, and the number that ties the two builds to one file:

    $ for f in s_dyn s_static; do
        printf "  %s:\n" $f
        readelf -lW $f | awk '/^  [A-Z]/ {
          t=$1; v=$3; fs=$5; fl=$6; if ($7=="E") fl=fl" E";
          printf "    %-7s vaddr=%-18s filesz=%-9s %s\n", t, v, fs, fl }'
      done
    
      s_dyn:
        PHDR    vaddr=0x0000000000000040  filesz=0x000310  R
        INTERP  vaddr=0x0000000000000374  filesz=0x00001c  R
        LOAD    vaddr=0x0000000000000000  filesz=0x0005d0  R
        LOAD    vaddr=0x0000000000001000  filesz=0x000141  R E
        LOAD    vaddr=0x0000000000002000  filesz=0x0000f8  R
        LOAD    vaddr=0x0000000000003e00  filesz=0x000210  R W
        DYNAMIC vaddr=0x0000000000003e10  filesz=0x0001b0  R W
        NOTE / GNU_PROPERTY / GNU_EH_FRAME / GNU_STACK / GNU_RELRO
    
      s_static:
        LOAD    vaddr=0x0000000000400000  filesz=0x000518  R
        LOAD    vaddr=0x0000000000401000  filesz=0x084a8d  R E
        LOAD    vaddr=0x0000000000486000  filesz=0x027f30  R
        LOAD    vaddr=0x00000000004ae108  filesz=0x00b180  R W
    

    Three things, and the third is the one that ties the module together.

    One: the dynamic binary has all four PT_LOADs at addresses near zero — 0, 0x1000, 0x2000, 0x3e00. That is because it is ET_DYN: the file records offsets relative to zero and the loader adds the base at run time. The Relocations course measured that base moving across runs; this is the same fact seen in the file rather than in a process.

    Two: the static binary is at 0x400000, and that is the script’s number.-static did not choose an address. It changed the e_type to ET_EXEC, and ld laid the image out at the fixed address the default script asks for:

    $ grep -n 'SEGMENT_START("text-segment"' default.ld | head -1
    13:  . = SEGMENT_START("text-segment", 0x400000) + SIZEOF_HEADERS;
    
      and the proof that -T moves it, from module 2:
      base=0x800000     first LOAD=0x0000000000800000   ran, exit=0
    

    Three: both have four PT_LOADs. The size changed by 52× and the segment count did not move at all, because the script’s very first line says why:

    $ head -1 default.ld
    /* Script for -z combreloc -z separate-code */
    

    -z separate-code is why a 52×-larger binary has the same number of segments. It forces the read-only headers, the executable code, the read-only data, and the writable data into four separate mappings, so the loader can apply W^X: code is never in a writable page. It costs a page of address space and one more mmap, and it is on by default. The default script you have never read is a different script depending on your flags, and the comment on line 1 is the only place that says so.

    And the honest cost that nobody puts in the man page:

      a static glibc binary does NOT work for:
        - getaddrinfo / NSS  (name resolution needs dlopen
                              of libnss_*.so at RUN TIME)
        - anything using dlopen
        - locales loaded on demand
        - gethostbyname, getpwnam, and friends
    
      and you find out at RUN TIME, on a machine that has
      every one of those libraries installed, because the
      failure is "cannot open shared object file" from a
      program that does not otherwise use shared objects.
    

    This is the real limit of -static on glibc and it is architectural, not a bug. Name resolution is specified to consult files in /etc at run time; making that work in a static binary requires dlopen, which requires a dynamic loader, which -static just removed. You cannot have both with this C library. The lesson is not “static is bad” — it is that a large fraction of “static linking” is really “which dynamic features am I giving up”.

    A Real Example

    Where -static genuinely is the right answer, and what the linker does that is not obvious — building a static binary with a script that knows about the absence of an interpreter:

      # the eleven-line script, extended for static
    
      OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64", "elf64-x86-64")
      OUTPUT_ARCH(i386:x86-64)
      ENTRY(_start)
      SECTIONS
      {
        . = 0x400000 + SIZEOF_HEADERS;
        .text  : { *(.text .stub .text.*) }
        . = ALIGN(0x1000);
        .rodata : { *(.rodata .rodata.*) }
        .data   : { *(.data .data.*) }
        .bss    : { *(.bss .bss.*) }
        /DISCARD/ : { *(.note.GNU-stack) *(.comment) }
      }
    
      $ clang -O1 -static hello.c -o hi -T mine.ld
      $ ./hi; echo "exit=$?"
      exit=0
      $ readelf -lW hi | grep -c INTERP
      0
    

    It works, and the /DISCARD/ line is doing real work. With a script you own, you can drop the section-name comments and the note sections outright, rather than letting the fallback place them. The previous concept measured what the fallback does with orphans you did not mention; here you mention everything, so nothing is guessed.

    And the honest comparison of the two ways to build a static binary, which is a decision people actually face:

      -static with the DEFAULT script
        + 0 lines of script to maintain
        + the layout is whatever 800+ people maintain
        -  you cannot change anything
        -  every section the script orphans is placed by
           a table inside the binary
    
      -static with YOUR script
        + full control, and nothing is guessed
        + you can drop sections, reorder, place absolutely
        -  you own the maintenance, and you will be wrong
           about a section name eventually
        -  -T implies EXEC, which is what you want here but
           is a trap if you forget it (module 2)
    
      the deciding question is not "which is better" but
      "do I need to change the layout". if no, use the
      default. if yes, you have no option.
    

    One more measurement, because it is the honest counterweight to the 52× and nobody mentions it. A static binary is not one binary in practice — it is one binary per libc. Linking against glibc statically binds you to that glibc, including its versioned symbol set. Build on a newer distribution, ship the binary, run it on an older one, and the kernel can load it — and libc’s copy of memcpy may not know about a kernel feature the CPU now offers. That is the cost, and it is a distribution problem rather than a linker problem, which is why “static binaries are portable” is true in the sense that matters and false in the sense people usually mean.

    Try It Yourself

    $ cd courses/link/assets/samples
    $ ./build_samples.sh 2>&2 | sed -n '/F12/,$p'
    $ python3 crosscheck.py 2>&2 | sed -n '/\\[12\\]/p'
    

    Then measure the thing nobody measures, which is what static actually costs at run time:

      1. Run ldd on both. Then run them under a
         strace that only counts OPENAT. How many files
         does each open before main()? (This is the
         real cost of dynamic linking and it is not the
         size of the binary.)
    
      2. Compile a program that calls getaddrinfo, once
         -static and once not. Run both. Does the static
         one fail? (If your distro's static glibc has NSS
         built in, it may work -- which is a MORE
         interesting result, and worth finding out which
         you have.)
    
      3. Now the experiment that ties the module together.
         Build s_static with YOUR script, change the base
         to 0x10000000, and run it. (It works. Then ask
         why -- which line of the script, and which
         concept measured it.)
    
      4. Build s_static with -Wl,-z,noseparate-code, and
         then check what ld --verbose says its script is.
         How many PT_LOADs now? (TWO, not three -- see
         below.) And which of the two builds has its
         READ-ONLY DATA in an executable segment? That is
         the whole security difference, and it is one flag.
    
      5. Strip both binaries. How much of the 52x is the
         symbol table rather than the code? (Hint:
         objcopy --strip-all, then compare .text.)
    

    Exercise 4 is the one that closes the loop on the first finding in this concept, and it is a one-flag experiment with a clean answer. -z noseparate-code takes four PT_LOADs down to three — the read-only data segment merges into the headers segment — and the script you were given changes accordingly, because the comment on line 1 literally names the flag. ld --verbose gives you a different 276-line script depending on your -z flags, and that is a fact worth holding onto before you go editing either one.

    Exercise 2 is the one that teaches the real constraint. Whether the static getaddrinfo works or fails tells you what your distribution’s static libc can do, and either answer is instructive: if it fails, you have the classic limitation; if it works, you have a build that silently links against a libc that will disagree with a different one. Both outcomes are things you need to know before shipping.

    Connect

    This is the first of the two closing concepts, and it is where the course’s earlier threads converge on one number. The Flags, and Which One Is Which Kind drew the 3×3 matrix of codegen against link flags and found four cells where the two disagree. PHDRS and the -T Surprise found that a script is a third participant which overrides both. This concept shows what that override is for:-static changes the e_type to ET_EXEC, the script then lays the image at a fixed address, and the kernel maps it with no loader involved. The 0x400000 is not a compiler decision or a linker decision in isolation — it is the script answering a question the code model asked.

    The connection to the ELF course's entry-point concept is the mechanism that makes a static binary work at all, and it is worth restating as the reason the whole thing is possible. PT_INTERP is a program header whose contents are a pathname. A dynamic binary is executed by a two-step process: the kernel maps the interpreter, the interpreter maps the program, and the interpreter reads e_entry from the program and jumps there. A static binary is one step: the kernel maps it and jumps to e_entry. Everything -static removes is the first half of that two-step process, and the segment measurements above are simply the absence of one header in a fourteen-header file.

    And the -z separate-code finding connects to the randomisation concept in a way that is easy to miss. That concept measured that a PIE gets a random base and argued that attacks (a) hard-coded addresses and (b) code pointers in .data stop working. Attack (d) — a vtable or function pointer in read-only data — was listed as not defeated by PIE, and the fix given was RELRO. Now you can see why the flag that produces four segments instead of two is the same decision:separate-code exists so that the read-only data segment — where those function pointers live — is in pages that are not writable, and merging it back with the headers would put it somewhere the loader might have to treat differently. Layout and security policy are the same lever.

    Forward to the last concept, which is the buildable artifact and the reason this course exists. Everything so far has been reading a 276-line file and running flags. Writing One Yourself makes you generate one, verify its effect, and run the result — and the tool for it, linklab.py, is deliberately shaped around the three things this course established: that a script is a program you can edit, that its effect is checkable rather than assumed, and that a linker will not tell you when your edit did nothing.

    One connection outward, and it is the honest limit of everything in this module. Every number on this page was measured on a hosted Linux target with a dynamic loader present. The e_type change, the removed PT_INTERP, the four segments, the 52× — all real, all reproducible from one script. But the question that matters for a static binary is “does it run on the machine I did not build it on”, and that is a question this toolchain cannot answer and this course does not claim to. It is the same limit the Relocations course recorded for AArch64: the mechanism is measured, the execution is not.

    Previous: The Sections the Script ForgotNext: Writing One Yourself