Back to course

Proving Your Object File Is Right

18 minintermediateModule 5: Emitting OneContinues from obj-arch-table
18 min · Module 5: Emitting One · Intermediate

Why This Matters

Here is the most uncomfortable fact in the previous concept, stated plainly: while building emit_elf.py, four bugs produced object files that every available tool read without a single warning.

  bug            readelf   llvm-readobj   llvm-objdump   ld    result
  -------------   -------   -----------   ------------   -----   --------------
  e_shoff wrong   ERROR     clean         clean         fail   loud, lucky
  sh_link/info    warns     clean         clean         ok     SYMBOLS EMPTY
  symbols overlap clean     clean         clean         ok     WRONG NUMBER
  add before call clean     clean         clean         ok     WRONG NUMBER

Read the last two rows. readelf, llvm-readobj-21 and llvm-objdump-21 all agreed completely with a file that computed the wrong answer. Agreement between readers is not evidence of correctness, because readers are being asked the wrong question. They are being asked “is this file well formed?” and the answer was yes. The question that mattered was “does this file mean what I meant?” and no reader was asked it, because no reader can be — a reader has no access to the intent.

So this concept is about the difference, and about the one technique that closes it. crosscheck.py in this course's assets/samples/ runs 95 checks over the specimens in this directory, and it deliberately uses three different kinds of evidence rather than three copies of the same one.

A Simple Model

Three tiers of evidence, ordered by how much they can catch and by how much they cost to set up. The ordering is the important part, because the cheapest tier catches the most.

TIER 3 the file disagrees with ITSELF Two places in one object record the same fact. COFF puts a section's size and relocation count in the section header AND in the symbol table's auxiliary record. ELF's symtab sh_info must be the index of its first GLOBAL symbol, and every entry before it must be LOCAL. Cheapest. Catches the most. Needs no second implementation and no external tool. TIER 2 your reader vs an UNRELATED toolchain Your decoder, written from the specification, diffed field by field against readelf and llvm-readobj-21. Two implementations that share no code. Medium cost. Catches a misreading of the spec. TIER 1 your EMITTER vs a reference producer Byte-compare your output against a file clang produced from equivalent source. Expensive. Catches the least, because a reference producer and you will disagree about section ordering, padding and naming for reasons that are not bugs.

Note the counter-intuitive ordering: the strongest evidence is the cheapest. That is because Tier 3 needs no second opinion at all — it is a self-consistency check, and an object file is unusually rich in redundant facts. The COFF case is the clearest: a reader that mis-parses the auxiliary records disagrees with itself, because the same section's size appears in the section header and in a symbol table entry, and they no longer match. That is a bug caught with no oracle at all.

And the reason Tier 1 is last despite being the most obvious choice: a reference producer is not an oracle for your file, it is an oracle for a file. It will differ from yours in section ordering, in how much padding it inserts, in whether it emits .comment, in the order of the symbol table — and every one of those differences is a legitimate choice. Diffing the whole file gives you a hundred differences of which perhaps one is a bug, which is a bad signal-to-noise ratio and a test that everyone learns to ignore.

The Actual Detail

Tier 3, and why COFF is the best teacher

demo_coff.o has PointerToSymbolTable = 0x211 and NumberOfSymbols = 26. The records are 18 bytes each, so the string table begins at 0x211 + 26*18 = 0x3e5. Now decode all 26 and count how many are symbols:

  26 records.  14 are symbols.  12 are AUXILIARY.

  A reader that ignores NumberOfAuxSymbols prints 12
  nonsense symbols, each with a garbage Value such as
  0x672d789d, and NOTHING LOOKS WRONG.

  And the check that catches it is one line, because
  every aux record carries facts the section header
  also carries:

  $ python3 crosscheck.py
    ok  aux record for .text: Length 62 == section RawDataSize 62
    ok  aux record for .text: RelocationCount 4 == header 4
    ok  aux record for .data: Length 16 == section RawDataSize 16
    ok  aux record for .bss:  Length 4  == section RawDataSize 4
    ok  aux record for .rdata: Length 6 == section RawDataSize 6
    ok  aux record for .rdata: Length 3 == section RawDataSize 3
    ok  every one of the 7 sections has a section-definition aux record

That is the entire technique, and it is worth noticing that the seven sections include two both named .rdata. So the check cannot be keyed by name — it must be keyed by index, which is itself a lesson from Sections, Compared. A validator that builds a dictionary from section name to section will silently lose one of the two .rdata sections, and the loss will not be an error.

ELF offers a different flavour of the same thing, and it caught a real mistake while this course was being written. The spec says a symbol table's sh_info is the index of the first symbol whose binding is not STB_LOCAL, and that all locals must precede globals. So a validator can check:

  $ python3 crosscheck.py
    ok  symtab sh_info == index of first GLOBAL symbol (4)
    ok  every symbol before sh_info is LOCAL, as the rule requires

Both are Tier 3: no second implementation, no external tool, just the specification's own internal consistency requirement, checked mechanically. And the ELF relocation sections give a third: sh_link must be a SHT_SYMTAB and sh_info must be a valid section index. Getting those two backwards — which is exactly bug 2 in the previous concept — is caught by a check that costs one line and needs no oracle.

Tier 2, and the question of what “independent” means

The interesting problem with Tier 2 is that independent is doing more work than it appears. On this machine there are two genuinely separate ELF toolchains — LLVM 21 and binutils — and for COFF there are two as well. For Mach-O there is one, because binutils refuses the format. That asymmetry is a fact about the world and it is worth stating rather than hiding, because it changes how much a Mach-O claim is worth.

claim about ELF or COFF two toolchains + hand-decoding + a decoder written from the spec = strong claim about Mach-O one toolchain + hand-decoding + a decoder written from the spec = weaker, and the course says so

And here is the sharpest point in the concept, which is about the failure mode of Tier 2 itself. A reader written from a specification and a reader written from the same specification share their author's misconceptions. If you misread the Mach-O r_info bit layout in your decoder, and you also misread it when you wrote the check, the two will agree perfectly and both will be wrong. Tier 2 only catches what the two implementations got independently right or independently wrong by different routes.

Which is why the strongest form of the check is a byte-exact structural diff over every field, compared as sorted sets rather than in order. The diff that found a real problem in this course was not a value mismatch; it was that readelf prints a section symbol's target as the section's name while the file stores st_name = 0 and the reader has to substitute one. That is a difference in presentation, discovered only by comparing everything, and it is exactly the kind of thing a spot check of “does the symbol name look right?” would have missed.

The oracle that caught the bugs the tools missed

So what actually caught bug 3, the overlapping symbols? Not a reader. The program's output. The expected answer was 41 and the program printed 389845008, and no tool in the world could have told you why — because the file was legal, the toolchain was correct, and the bug was in a description of intent that existed nowhere except in the emitter's head.

the loop that works, in order: 1 Tier 3 on every file, every build. Cheap. Catches mis-parsed tables. 2 Tier 2 against a toolchain you did not write. Catches a misread specification. 3 EXECUTE, and compare against a value you computed by hand BEFORE running. Catches everything else, and it is the only tier that can catch a wrong intention.

And the ordering of step 3 is not incidental. If you run the program first and then work out what it should have printed, you will rationalise whatever it printed. If you write down start(5) = helper(5) + 1 = 11 and answer = 41 before linking, then a mismatch is unambiguous — and in this course it was unambiguous immediately, which is how the overlapping symbols were found in minutes rather than days.

The general form of this is worth carrying outside the course. A verifier that shares an assumption with the thing it verifies is not a verifier. Three tools agreeing means three tools answered the question you asked; it does not mean the question was the right one. The tiers are ordered by independence, not by how many tools were run, and the only tier that can catch an error of intention is the one where you state your intention somewhere the tools cannot see it — a test, a hand calculation, a comment you then check against reality. That is the whole discipline, and it is the same one the course has applied to itself: the -0x4 claim, the Mach-O linkonce claim, and the string-table claim in Where Names Live were all found by building a specimen and measuring, not by reading a specification and believing it.

A Real Example

What the harness actually reports, and what each block of it is for:

  $ python3 crosscheck.py
  ========================================================================
  Object Files -- crosscheck
  Three tiers: independent decoder, external toolchain, and the
  file's own internal invariants. Tier 3 catches the most.
  ========================================================================

  demo_elf.o  (ELF64 x86-64 relocatable)
    ok    e_type == 1 (ET_REL) -- this is a relocatable file
    ok    e_phnum == 0 -- no program headers, and that is not an omission
    ok    [3] .rela.text sh_link points at the symtab
    ok    [3] .rela.text sh_info is a valid target section (.text)
    ok    [5] .rela.data sh_link points at the symtab
    ok    [5] .rela.data sh_info is a valid target section (.data)
    ok    [12] .rela.eh_frame sh_link points at the symtab
    ok    [12] .rela.eh_frame sh_info is a valid target section (.eh_frame)
    ok    symtab sh_info == index of first GLOBAL symbol (4)
    ok    every symbol before sh_info is LOCAL, as the rule requires
    ok    TIER 2: all 8 relocations match readelf exactly
    ok    .text relocations are the ones the course claims
    ok    section names are exactly the ones the course lists
  ...
  hand.o  (emitted by emit_elf.py, no library involved)
    ok    e_type == ET_REL
    ok    e_phnum == 0
    ok    exactly 8 section headers, as emit_elf.py says
    ok    section 5 is .symtab and section 6 is .strtab
    ok    the five symbols are the ones emit_elf.py declares
    ok    answer (4 bytes at 0) does not overlap answer_ptr at 4
    ok    .data is big enough for both
    ok    TIER 2: a real linker accepts it (ld -r exit 0)
  ...
  ========================================================================
  95 passed, 0 failed
  ========================================================================

Two lines in that output are worth reading twice, because they are the harness doing the thing no single tool does.

“answer (4 bytes at 0) does not overlap answer_ptr at 4” is a Tier 3 check on the hand-built file, and it exists precisely because the overlapping version of that file was produced and shipped by mistake. It is not a specification requirement — ELF permits overlap — it is a check against this emitter's intent, written after the bug. That is the honest shape of the technique: the checks you end up with are the ones your past mistakes demanded, and the harness is a list of scar tissue.

“TIER 2: a real linker accepts it (ld -r exit 0)” is the only check that involves a different program entirely, and it is one line. It is also the only check that would have caught bug 1 — the wrong e_shoff — instantly, because GNU ld would have refused the file rather than trying to interpret it. Which raises the obvious question: why not just always run the linker?

Because a linker accepts plenty it should not, and refuses some it could cope with. It accepted the file with overlapping symbols. It accepted the file with a mov whose add was in the wrong place. It did not complain about a .text section containing something that is not instructions. The linker is a consumer with needs, not a validator, and treating its silence as approval is the same mistake as treating readelf's silence as approval — one tier up.

Try It Yourself

$ cd courses/obj/assets/samples
$ python3 crosscheck.py            # 95 checks
$ python3 crosscheck.py -v         # show the diffs
$ python3 ardec.py libdemo.a
$ sh build_samples.sh              # rebuild every specimen

Check Your Understanding

Without looking back: you have written a decoder for an object format from its specification, and a second tool on your machine also reads the format. Both agree on every field of all 400 files in your corpus. Your decoder is nonetheless wrong about one field, in a way that makes it report a plausible but incorrect value. How is that possible, and what would have caught it?

Apply It

You maintain a tool that reads a binary format, and a user reports that it misparses one file out of ten thousand. You have checked the file with your own reader, with the reference implementation, and with a second third-party library — all three agree, field for field. The file is well formed by every tool available. Given the tiers in this concept, what is the most likely explanation, what is the first thing you should do, and what is the thing you are most likely to do instead that will waste a day?

The general habit: when every tool agrees and the answer is wrong, the tools are not the variable. Go get the bytes, then look for a fact the format records twice, and treat any investigation in which nothing can fail as a signal that you are in the wrong place.

Connect

This is the last concept of the course, and it is the one that makes the other seventeen safe to trust. Everything asserted in this course was measured on committed, byte-reproducible specimens, and the three claims that were wrong — the addend's location, Mach-O's linkonce mechanism, and ELF's two string tables — were all wrong in the same way: they were plausible readings of a specification that had been consulted rather than a file that had been opened. That is the argument for the whole method, stated as three corrections.

The connection to Writing One From Scratch is direct, and the four bugs are the evidence. Every one of them got past every reader, and two of them produced a program that ran and printed a plausible wrong number. The encodings concept adds the fifth that did not happen — a relocation offset one byte off the field — which on x86-64 produces a binary that links and jumps slightly wrong, because a fixed four-byte field gives nothing to signal the mistake. Between the two concepts is the whole argument for a verification discipline: agreement is cheap, and it is cheap precisely because readers are answering “is this well formed?” rather than “is this what you meant?”

The connection to The archive is a Tier 3 opportunity that the format hands you for free, and it is the clearest one in the course. An archive's index says which member defines a symbol, and each member's own symbol table independently says what it defines. Two records of the same fact, in two different files, which is exactly the shape a self-consistency check needs — and the course uses it, cross-checking all four index entries against the members they point at. This is also where the COFF course's ar_decode.py and this course's version of it are the same idea applied twice, which is the triangulation principle at the level of tooling rather than of formats.

The COFF connection is the strongest, because COFF is where redundancy is densest. The same facts appear in the section header and the auxiliary record; the same class of data appears in the symbol table and the string table; the section name appears both inline and as an escape into that table. That density is not a design virtue — it is the residue of a format that grew by accretion, and the COFF course documents three places where the specification and the actual emitters disagree. But the accidents of a format are often more useful than its intentions, because a redundant fact is a free consistency check that nobody had to design. The best validation harnesses are built out of the parts of a format nobody chose on purpose.

And the connection outward, to the whole course collection, is the method rather than the format. Every course here claims the same thing in a different costume: that a claim is worth teaching once two implementations that share no code agree on it. The JVM course has a crosscheck.py, the COFF course has one, the WebAssembly course has one, and this course has one. They are not identical, and they should not be: a harness is specific to what a format makes checkable. What they share is the tiering, and the tiering is the transferable part. Run the self-consistency checks first because they are cheapest and catch the most. Diff against an unrelated toolchain second, because that catches a misreading of the specification. Execute against a hand-computed expectation last, because that is the only tier that can catch an error of intention — and an error of intention is the only kind that no amount of reading will ever find.

What the course leaves you with, stated as one sentence. You can now read an ELF, COFF or Mach-O object file with nothing but the bytes and these eighteen pages; you know that every field in it exists because a linker needs it for a specific reason; and you can write one — a small one, byte by byte, with no library — that a production linker accepts and that computes the right answer. Those three sentences are the whole course, and the third one is what the first two are for. The next link in the chain is the linker itself, and you now have everything it needs to be written from scratch.