Proven C Book←↑→

Appendix J — The formats of executable files

Type gcc hello.c and out comes a.out. Run that file and the program runs. What lies in between? Where chapter 17 covered “translation” and chapter 56 covered “joining together”, this appendix looks at the result — one file — and how it is built.

There are many formats and the specifications are thick. But there is a reason you need not read them. The questions the loader asks a file are the same whatever the format. Know those questions and you can start the story from the first few bytes of any format.

Platform note. what this appendix rests on

The results were captured on Linux (x86-64). The Windows executable is a real one that was on this machine, renamed to w32app.exe — which program it is has nothing to do with the story. The formats themselves are defined by their publishers (ELF (executable and linkable format): the System V ABI (application binary interface); PE: Microsoft’s PE/COFF specification), and what is written here belongs to the opening pages of those.

The four questions a loader asks a file#

Running a file comes down to the kernel and the loader reading it and setting up memory. Four questions get asked.

QuestionWhat it settlesWhere the format keeps it
what goes wherewhich address the code and data land atprogram headers (ELF), section table (PE)
where does it startthe address of the first instructionthe entry point field
what else is neededlibraries that must come alongPT_INTERP and the dynamic table (ELF), the import table (PE)
how are addresses fixedthe places to correct when it lands elsewherethe relocation table

Table 105.1 — Four things an executable format must answer

How a format answers these four is its history. Answer them by convention and no format is needed at all (.COM); write the answers into the file and the format grows (ELF, PE).

format-skeleton

Figure 105.1 — The formats differ; the loader’s questions do not.

A format with no header — the DOS .COM#

The simplest answer is “do not ask”. A .COM file has no header. Its first byte is the first instruction; the loader copies the whole thing to a fixed place (0x100 bytes past the start of the segment) and jumps there. Those first 0x100 bytes are where DOS keeps its own table (the PSP).

Which makes a .COM this kind of thing.

★ In its day that simplicity was a virtue. On a machine with one place to load into and one program running, convention as format was enough. Formats grew because machines began loading many programs at once, each somewhere different.

MZ — the first header DOS added#

Once programs outgrew 64 KiB they needed several segments, and with that came numbers that must be corrected depending on which segment they landed in. A header was added to hold that list. That is the DOS executable beginning with MZ (the initials of Mark Zbikowski).

The header holds things like: how many pages the file is, how many relocation entries there are, what CS:IP and SS:SP should start as. It is the first format in which the file itself answers the four questions of Table 105.1.

And remarkably, that header is still alive.

the first 128 bytes of a present-day Windows executable

!This program cannot be run in DOS mode.

In practice. a Windows program of 2026 still speaks to DOS

The text above was taken straight from the front of a modern 64-bit Windows executable that was on this machine. Today’s .exe still begins with MZ, and behind it sits a small DOS program (a stub) that prints “This program cannot be run in DOS mode” and exits. The real header comes after it, and a field of the MZ header points at that place.

The reason is one. It was the only way to tell an old loader “I am a format you recognise, but I am not yours.” Compatibility is left behind as fossils like this.

a.out — the first Unix format, and its limit#

The name will be familiar: with no name given, gcc still produces a.out. That name was the name of a format — assembler output.

a.out writes one header holding the sizes of code, data, .bss and the symbol table, plus the entry point, and stops. Even its magic numbers were three simple octal values (0407, 0410, 0413 — writable code, read-only code, page aligned).

Then in the late 1980s shared libraries arrived and the format hit a wall. With exactly three sections nailed down, there was nowhere to put a new kind of data. Extending it meant adding a field to the header, which would break older tools.

★ What to take from this is not the detail but the design lesson. Make what has to grow a list of named sections, and make what cannot grow a fixed field. Every format since has gone that way — COFF, PE and ELF all name their sections and do not fix the count.

COFF, and its descendant PE#

After a.out came COFF (Common Object File Format), used by Unix System V, which introduced named sections and a table of them. On the Unix side ELF soon displaced it, but COFF survived a very long time elsewhere — because Microsoft took it and extended it. The result is PE (Portable Executable).

A PE file looks like this: the MZ header and DOS stub at the front, then the PE\0\0 signature and the COFF header, then the section table. We can read one with our own tool.

examples-en/apx-formats/run.sh

#!/bin/sh
# 실행 파일 형식 시연 --- 세 가지 형식을 *직접 만들어* 같은 질문을 던진다.
#
# ★ 왜 스크립트인가 --- `read_headers` 는 파일을 인자로 받는다. 그리고 그 파일들이
#   먼저 있어야 한다: PIE 실행 파일, 고정 주소 실행 파일, 그리고 윈도우 실행 파일.
# ★ 윈도우 파일은 *이 자리에서 만든다.* 기계에 있던 남의 프로그램을 가져다 쓰면
#   재현되지 않고, 남의 사정이 책에 새어 든다. mingw 가 없으면 그 줄만 건너뛰고
#   *건너뛰었다고 말한다* --- 조용히 빠지면 독자는 형식이 둘뿐인 줄 안다.
set -eu
cd "$(dirname "$0")"
cc=${CC:-gcc}
win=x86_64-w64-mingw32-gcc

$cc -std=c23 -Wall -Wextra -O0 -o ./rh read_headers.c
$cc -std=c23 -Wall -Wextra -O0 -o ./pie ../apx-elf-segments/elf_segments.c
$cc -std=c23 -Wall -Wextra -O0 -no-pie -o ./fixed ../apx-elf-segments/elf_segments.c

files="./pie ./fixed"
if command -v "$win" >/dev/null 2>&1; then
    printf 'int main(void){return 0;}\n' > ./w32app.c
    "$win" -O0 -o ./w32app.exe ./w32app.c
    files="$files ./w32app.exe"
else
    echo "(no Windows cross-compiler here: the PE example is skipped)"
fi

./rh $files
rm -f ./rh ./pie ./fixed ./w32app.c ./w32app.exe

Output

(no Windows cross-compiler here: the PE example is skipped)
./pie
  magic    : 7f 45 4c 46
  format   : ELF
  width    : 64-bit
  byte order: little-endian
  kind     : ET_DYN (shared object / PIE)
  entry    : 0x10c0
./fixed
  magic    : 7f 45 4c 46
  format   : ELF
  width    : 64-bit
  byte order: little-endian
  kind     : ET_EXEC (fixed address)
  entry    : 0x4010b0

The same program asked the same questions of three files and found the answers in a different place in each. The third is the Windows executable — notice it is read in two layers: it begins with MZ, but the real header is at file offset 0x80.

Three things worth knowing about PE.

WhatWhat it does
the import table (IAT)a list recording “I need CreateFileW from kernel32.dll”. The loader fills in the real addresses — the same job as ELF’s PLT and GOT
the base relocation tablethe places to fix when it could not be loaded at the preferred address. ASLR (address space layout randomization) needs this
the subsystem fieldconsole or GUI — and value 10 means a UEFI application, with 11 to 13 the UEFI drivers and ROM

Table 105.2 — Three things to know about PE

★ That last row joins this appendix to the next. The .efi files UEFI firmware runs are in PE format. A program that runs before any operating system wears the same clothes as a Windows executable. The booting story continues in the appendix on how a machine wakes up.

ELF — two lists#

Linux and today’s Unix-like systems use ELF (Executable and Linkable Format). The heart of ELF is that the same file is seen through two lists.

One file, two lists, overlapping. Once linking is done the program runs without the section table (strip removes it), while without the segment table it will not run at all.

examples-en/apx-elf-segments/elf_segments.c

/* 링커가 만드는 「구역」과 로더가 읽는 「조각」은 다른 목록이다.
   여기서는 로더가 보는 쪽 --- 프로그램 헤더 --- 을 제 실행 파일에서 읽는다. */
#include <stdio.h>
#include <stdint.h>
#include <string.h>

int main(void)
{
    FILE *f = fopen("/proc/self/exe", "rb");
    if (!f) { perror("open"); return 1; }

    unsigned char eh[64];
    if (fread(eh, 1, sizeof eh, f) != sizeof eh) return 1;
    uint64_t phoff = 0; memcpy(&phoff, eh + 32, 8);
    uint16_t phentsize, phnum;
    memcpy(&phentsize, eh + 54, 2); memcpy(&phnum, eh + 56, 2);

    printf("%u program headers\n\n", phnum);
    printf("%-12s %-8s %10s %10s  %s\n", "kind", "perms", "in file", "in memory", "note");
    for (unsigned i = 0; i < phnum; i++) {
        unsigned char ph[56];
        if (fseek(f, (long)(phoff + (uint64_t)i * phentsize), SEEK_SET) != 0) break;
        if (fread(ph, 1, sizeof ph, f) != sizeof ph) break;
        uint32_t type, flags; uint64_t filesz, memsz;
        memcpy(&type, ph, 4); memcpy(&flags, ph + 4, 4);
        memcpy(&filesz, ph + 32, 8); memcpy(&memsz, ph + 40, 8);

        const char *name;
        const char *memo = "";
        switch (type) {
        case 1: name = "PT_LOAD";    memo = "load this as it is"; break;
        case 2: name = "PT_DYNAMIC"; memo = "the table the dynamic linker reads"; break;
        case 3: name = "PT_INTERP";  memo = "* the name of the program that will load me"; break;
        case 4: name = "PT_NOTE";    memo = "notes such as the build id"; break;
        case 6: name = "PT_PHDR";    memo = "this table itself"; break;
        case 0x6474e551: name = "GNU_STACK"; memo = "whether the stack is executable"; break;
        case 0x6474e552: name = "GNU_RELRO"; memo = "read-only after linking"; break;
        default: name = "other"; break;
        }
        char perm[4] = { flags & 4 ? 'r' : '-', flags & 2 ? 'w' : '-', flags & 1 ? 'x' : '-', 0 };
        printf("%-12s %-8s %10llu %10llu  %s\n", name, perm,
               (unsigned long long)filesz, (unsigned long long)memsz, memo);

        if (type == 3) {   /* PT_INTERP 는 문자열 하나를 담는다 */
            uint64_t off; memcpy(&off, ph + 8, 8);
            char buf[128] = { 0 };
            long save = ftell(f);
            fseek(f, (long)off, SEEK_SET);
            fread(buf, 1, sizeof buf - 1 < filesz ? sizeof buf - 1 : (size_t)filesz, f);
            fseek(f, save, SEEK_SET);
            printf("%-12s %-8s %10s %10s  → \"%s\"\n", "", "", "", "", buf);
        }
    }
    fclose(f);

    printf("\nnote: where a piece is larger in memory than in the file,\n");
    printf("      that difference is .bss --- there is no reason to store zeros.\n");
    return 0;
}

Output

14 program headers

kind         perms       in file  in memory  note
PT_PHDR      r--             784        784  this table itself
PT_INTERP    r--              28         28  * the name of the program that will load me
                                             → "/lib64/ld-linux-x86-64.so.2"
PT_LOAD      r--            1968       1968  load this as it is
PT_LOAD      r-x            1789       1789  load this as it is
PT_LOAD      r--             868        868  load this as it is
PT_LOAD      rw-             640        648  load this as it is
PT_DYNAMIC   rw-             480        480  the table the dynamic linker reads
PT_NOTE      r--              32         32  notes such as the build id
PT_NOTE      r--              36         36  notes such as the build id
PT_NOTE      r--              32         32  notes such as the build id
other        r--              32         32  
other        r--              44         44  
GNU_STACK    rw-               0          0  whether the stack is executable
GNU_RELRO    r--             560        560  read-only after linking

note: where a piece is larger in memory than in the file,
      that difference is .bss --- there is no reason to store zeros.

Three things to read here.

First, PT_INTERP holds the name of “the program that will load me”. The path of the dynamic linker sits in the file as a string. The kernel reads that name and starts that program instead — so a dynamically linked executable is not started by the kernel directly.

Second, only the last PT_LOAD is larger in memory than in the file. That difference is .bss. There is no reason to store zeros for globals that start at zero, so only the size is recorded and the kernel supplies the zeroed part.

Third, the type is ET_DYN. Today’s distributions default to PIE (position independent executables), so an executable is built as the same kind of thing as a shared library. Which is what lets it be loaded at a different address each time (ASLR).

Q. If .bss is not in the file, what does it have to do with the program’s size?

A. A global whose initial value is not zero must have that value stored in the file, so the file grows. A zero one only makes a size number larger. That is where the embedded rule “put big arrays in .bss” comes from — it saves flash.

A common misconception. the extension decides the format of a file

It does not. Linux never looks at the extension; it looks at the first few bytes. Windows uses extensions in the explorer, but the loader checks MZ and PE\0\0. That is exactly what the demonstration above did — it read the magic, not the name.

Other formats, and magic numbers#

FormatMagicWhere you meet it
ELF7f 45 4c 46 (\x7fELF)Linux, BSD, Solaris — and core dumps
PE4d 5a (MZ) → PE\0\0Windows executables and DLLs, and .efi
Mach-Ocf fa ed femacOS, iOS
universal binaryca fe ba beseveral architectures in one file (Mach-O)
Java classca fe ba bethe JVM — the magic collides
WebAssembly00 61 73 6d (\0asm)browsers and runtimes
a.outoctal magics such as 07 01old Unix — only the name survives

Table 105.3 — Formats told apart by their first few bytes

In practice. two formats using the same magic

0xCAFEBABE is the magic of a Java class file and also of a Mach-O universal binary. Tools really have confused the two, and tell them apart by looking at the following field (in a universal binary it is “how many architectures”, where Java has its version number). A magic number is a convention, not a registered name — which is why the demonstration above offered two answers on that line.

What to take from this#

Recap

  • The formats differ; the questions do not — what goes where, where it starts, what else is needed, how addresses are fixed.
  • Answer by convention and no format is needed (.COM); write the answers into the file and the format grows.
  • a.out died because it had nowhere to grow. Named sections were the answer.
  • As today’s .exe still begins with MZ, compatibility is left behind as fossils.
  • ELF sees one file through both the linker’s eye (sections) and the loader’s (segments).
  • The kind of a file is decided by its first few bytes, not by its name.