A section is the unit of everything. The linker groups sections, decides which to keep, assigns addresses by section, and computes permissions per section-group. Every interesting behaviour in an object file is a section behaviour wearing a different hat — so if you misread the section table, nothing afterwards can be right.
And the section table is where the three formats disagree most, by a wide margin. They do not merely encode the same facts differently; they do not even agree on which facts exist. A COFF section has a VirtualAddress field that is always zero in an object. A Mach-O section has a reserved1 and reserved2 that are always zero. ELF has neither, and has a sh_flags field where COFF has Characteristics and Mach-O has flags and both are section-type enums rather than attribute sets.
This concept reads the three section tables out of one file each, byte by byte, because this is the part of the format where a tool author has the most to get wrong and the least excuse when they do.
What a section has to answer, and then the three ways of answering it:
Notice the size spread: 40, 64, 80 bytes for a concept that is the same in all three. Mach-O's extra 16 bytes are half the space of a whole ELF field, spent on a 16-byte name that will hold __cstring and thirteen zeros. That is not waste exactly — it buys a format with no string table for section names at all — but it is a real cost paid per section, and it is why Mach-O objects are not the smallest of the three.
Now the alignment, which is the single most dangerous field in the whole concept, because all three store an integer and two of them mean different things by it.
All three section tables, decoded from the specimens. COFF first, because its 40-byte record is the easiest to read and it contains the finding that motivates this concept:
COFF, 20-byte file header then 7 x 40-byte records section headers start at 0x14 [0] b'.text\x00\x00\x00' rawsize=62 rawoff=0x12c reloc=0x16a n=4 [1] b'.data\x00\x00\x00' rawsize=16 rawoff=0x192 reloc=0x1a2 n=1 [2] b'.bss\x00\x00\x00\x00' rawsize=4 rawoff=0x0 reloc=0x0 n=0 [3] b'.rdata\x00\x00' rawsize=6 rawoff=0x1ac reloc=0x0 n=0 [4] b'.rdata\x00\x00' rawsize=3 rawoff=0x1b2 reloc=0x0 n=0 [5] b'.debug$S' rawsize=92 rawoff=0x1b5 reloc=0x0 n=0 [6] b'/45\x00\x00\x00\x00\x00' rawsize=0 rawoff=0x211 reloc=0x0 n=0
Every one of those `name_field` values is the raw 8 bytes out of the file, and three things fall straight out of the column.
Records [3] and [4] are separate sections with separate file offsets (0x1ac and 0x1b2) and separate sizes (6 and 3). They are the two string literals, "hello" and "hi". Section names are not unique in COFF, and this is not a compiler quirk — it is a direct consequence of storing the name inline where nothing enforces uniqueness.
The consequences for a linker are concrete. A symbol's section is a number, so a correct linker is unaffected. But anything that builds a name-keyed dictionary loses one of these silently, and the loss is invisible because the dictionary still has an entry called .rdata — just the wrong one. And note record [2], .bss: rawsize=4 but rawoff=0x0. Four bytes of uninitialised data, occupying no file bytes at all, because there is nothing to store.
Eight bytes are not always enough, and COFF's answer is an escape rather than a longer field. A name beginning with / followed by decimal digits means that decimal offset into the string table. This specimen uses it:
PointerToSymbolTable = 0x211 NumberOfSymbols = 26 string table starts at 0x211 + 26 * 18 = 0x3e5 name field says /45 -> string table offset 45 decimal d[0x3e5 + 45] = '.llvm_addrsig\x00' VERIFIED
Decimal, not hexadecimal — a detail worth knowing before you spend an afternoon on it, because 0x45 is 69 and lands in the middle of the symbol names instead. So COFF has both mechanisms: an inline field for the common case, and a string-table escape for names that do not fit, which is how .debug$S above fits in 8 bytes and .llvm_addrsig fits in 3. ELF needs no escape because it has no inline field to overflow.
Now Mach-O, 80-byte records inside one LC_SEGMENT_64:
LC_SEGMENT_64 segname = 16 zero bytes (UNNAMED)
vmaddr=0x0 vmsize=252 nsects=6
sect addr size offset align relocs
__text 0x00 67 704 4 = 16 bytes 4
__data 0x48 16 776 3 = 8 bytes 1
__cstring 0x58 6 792 0 = 1 byte 0
__const 0x5e 3 798 0 = 1 byte 0
__common 0xf8 4 0 2 = 4 bytes 0
__eh_frame 0x68 144 808 3 = 8 bytes 0
Now put the ELF alignments next to Mach-O's, because this is the whole concept in one comparison:
ELF Mach-O code section AddressAlignment: 16 align: 4 data section AddressAlignment: 8 align: 3 ELF stores a BYTE COUNT. Mach-O stores a LOGARITHM. 16 bytes is 4 in log2. 8 bytes is 3 in log2. they describe the same two alignments, and a tool that copies one format's field into the other is wrong by a factor of 4 and 8 respectively.
And both wrong answers are plausible. 16 and 4 are both powers of two; 8 and 3 are both small integers. Nothing looks wrong. The result is a section placed at an address that satisfies the wrong constraint, which links cleanly and then faults at run time if the code needs the stricter alignment. This is the single most dangerous field in the object format, and it is dangerous precisely because getting it wrong produces a number that looks like an answer.
Two more Mach-O details worth having. offset=0 on __common — four bytes of uninitialised data with no file bytes, exactly as COFF's .bss at rawoff=0x0, and for the same reason. And the section flags carry the type as an enum: __cstring has 0x00000002 and __common has 0x00000001, which is how a reader tells a zero-fill section from one with bytes without consulting the offset.
The three strategies, with the raw bytes that prove them:
ELF the name is a NUMBER. demo_elf.o's section
header for .text stores sh_name = 6, and byte 6 of
the table at e_shstrndx is where "text" begins.
Nothing in the section header is a name.
(That table is also .symtab's sh_link, and it is
named .strtab -- see the REALITY unit.)
COFF 8 bytes inline: b'.text\x00\x00\x00'
b'.rdata\x00\x00'
b'/45\x00\x00\x00\x00\x00' -> escape
Mach-O 16 bytes inline: b'__text' + 10 zero bytes
b'__cstring' + 7 zero bytes
and a SECOND 16-byte field for the
segment name: __TEXT or __DATA
Mach-O stores two names per section, 32 bytes, and uses one of them for something the other two formats have no concept of. Every Mach-O section declares which segment it belongs to — __text says __TEXT, __data says __DATA — and in an object that grouping is inert, because the single segment command is unnamed. In a linked binary it is the whole point. A Mach-O section header reserves 16 bytes for a fact that is always meaningless in the file type where you are most likely to be reading it.
And the question everyone asks eventually: does ELF need a second string table? The answer is that it has two slots and does not necessarily have two tables — and the specimen in this course proves it. Read the section header table of demo_elf.o and you will find e_shstrndx = 1. Read the symbol table's header and you will find sh_link = 1. Both point at the same section, and that section is named .strtab. There is no .shstrtab in the file at all.
The convention is real — GCC's build of the same source does have both, with e_shstrndx = 16 and sh_link = 15 — and the merged table is legal, because the specification says section names come from “the string table named by e_shstrndx” and says nothing about that table having to be distinct from .strtab or having to be called .shstrtab. The contents of the merged table make the reason obvious: it holds section names and symbol names interleaved — .rela.text, call_out, comment, bss, message_bytes, global_counter — and nothing about the file suggests which category any given string belongs to.
e_shstrndx names the string table for section names; sh_link of a symbol table names the one for symbol names. They are usually different and they are not required to be, so a reader that looks for a section called .shstrtab works on GCC output and fails on clang output. A tool that assumes two tables by name will work on one of the two most common producers on Earth and break on the other, in a way that produces empty symbol names rather than an error. COFF and Mach-O, by contrast, need only one string table each and pay for it in fixed-size fields and an escape hatch.What a linker actually does with this table, because that is the reason any of the fields exist. Six jobs, and for each one, which field it needs:
Job 6 is the subtle one and it is where the two .rdata sections become a genuine hazard rather than a curiosity. Permissions are decided per section, and the linker sorts sections into permission groups — which means it groups by name. If your dictionary is keyed by name and .rdata appears twice, one of the two groups is wrong, and the resulting segment either over- or under-permits a page. One of those is a security bug.
Job 3 is where the alignment units bite, and the honest statement is that this is not a hypothetical. A linker that reads Mach-O's align as a byte count places __text at offset 4 instead of 16. For code that never happens to need 16-byte alignment it works perfectly, which is what makes it survive testing. It fails on a function with a 16-byte-aligned movaps, at run time, on some inputs.
.rdata collision is identity: the format does not promise names are unique, and a reader that assumes they are will merge two things. An alignment misread is units: the format stores a number and the reader supplies the wrong scale. In both cases the failure is silent, the output is structurally valid, and the bug surfaces as wrong behaviour at run time rather than as a rejected file. The defence is the same in each case and it is not defensive programming — it is reading the specification's field definition rather than inferring it from the values you happen to see. A field's meaning is not evident from its plausible values, and the whole course is built on measurements that keep showing that.One more practical thing, because it decides how you write a reader. COFF's VirtualAddress and Mach-O's reserved1/reserved2 are always zero in an object file, and that is not a coincidence to be documented — it is a consequence of the previous concept. Addresses are not knowable at object time, so a format that inherited the field from its executable form carries a dead field. A reader should skip zero-valued fields rather than validate them, and a writer must not try to fill them in. Compare ELF, which has no such field in Elf64_Shdr at all — not because the designers forgot, but because they were not constrained by an existing layout.
$ llvm-readobj-21 --sections demo_coff.o
$ readelf -SW demo_elf.o
$ llvm-objdump-21 -h demo_macho.o.rela.* sections. Work out for each of ELF's extras whether it is data or metadata, because that distinction is exactly why a format ends up with sixteen where another has six./45 escape and verify it yourself. The string table is at PointerToSymbolTable + NumberOfSymbols * 18 = 0x211 + 26*18 = 0x3e5. Offset 45 decimal gives .llvm_addrsig. Then try offset 45 in hex (69) and confirm it lands in the symbol names instead. Getting this arithmetic wrong on the first attempt is the normal experience and the reason it is worth doing.AddressAlignment: 16, Mach-O align: 4. Write the two lines of code that get it wrong and then compute what alignment your __text section would actually get. Then find a Mach-O section in some real binary where it matters.demo_coff.o and print its size. It will hold six entries for seven sections, and printing the surviving .rdata's offset tells you which literal you kept. This is a five-line bug that a real linker has shipped..bss-shaped section in all three. COFF .bss at rawoff=0x0 with rawsize=4; Mach-O __common at offset=0 with size=4. Add a third uninitialised variable to demo.c, rebuild, and watch the size change while the offset stays zero. Then ask your reader what it should copy — the answer is nothing, and getting that wrong writes four bytes of zeros over something else.e_shstrndx out of the ELF header — for demo_elf.o it is 1 — and notice that section 1 is named .strtab, not .shstrtab. Then read the .symtab header's sh_link and confirm it is also 1. Both walks land in the same table. Now repeat the whole thing on demo_elfgcc.o, where the two indexes are 15 and 16 and the tables really are separate. A reader that follows the indexes works on both; one that looks for .shstrtab by name works on exactly one of the two most common producers on Earth, and fails by printing empty names rather than by erroring.Without looking back: ELF records AddressAlignment: 16 for the code section and Mach-O records align: 4 for the same section. A linker reads the Mach-O field and uses it directly as a byte count. What alignment does the section actually get, what is the earliest instruction that would fault because of it, and why is the bug likely to survive a test suite?
You are writing the section-gathering pass of a linker. It reads each input's section table into a dictionary keyed by section name, merges entries with equal keys, and emits one output section per key. On a build that mixes clang and gcc it produces an executable that runs. On a build that uses MSVC-style COFF objects it produces one that passes every unit test and then crashes in a data-loading path, and only on Windows. The output has one fewer read-only segment than the inputs appear to justify. What is the defect, and what is the one change that fixes it without giving up name-keyed grouping?
The general habit: before using any identifier a format hands you as a dictionary key, check that it is actually unique in that format. This course's COFF specimen is a committed, reproducible counterexample — two sections, one name.
This concept is the ELF course's section header table and the PE course's section table compared against each other and against a third, which is the only way the accidental parts become visible. The COFF course is closest to this material — its first module is "The Relocatable Object" — so if you have read it, the job here is the part it cannot do alone: showing that a decision COFF made for its own reasons is a decision, and that two other teams made the opposite one.
The alignment-units trap has a direct counterpart in the architecture section, and it is the same lesson wearing different clothes. The Memory Hierarchy course is where a page is 4096 bytes and an L1 cache line is 64, and where a field that says "alignment 4" is ambiguous between the two unless the specification says which. Mach-O's align is a logarithm for the same reason a memory-ordering constant is named for what it prevents rather than what it does: a number in a header needs a unit, and the unit is the specification's job to state. A field whose meaning depends on a convention outside the file is a field a reader cannot check.
The string-table decision is the same shape as the JVM course's finding that CONSTANT_Utf8 is not UTF-8, and for the same underlying reason: a named encoding in a format is a claim about the bytes, and the claim can be false or can be one of several things. Here the claim is "this 4-byte number is a name", and it is true only because a specification says a string table exists at a known index. The name of that table is the part that is not guaranteed, and the file in this course proves it: clang calls it .strtab and points e_shstrndx at it, while GCC calls it .shstrtab and points e_shstrndx at that instead. Same specification, same source, two different answers — which is the strongest possible form of the lesson, because it is not one producer being wrong. A reader that infers a table from its name rather than from the index it was given will work on one of them and fail on the other.
And the .bss-shaped sections — nonzero size, zero offset — connect to ELF's common sections and to the tentative-definition machinery that obj-bss-common covers. The important thing to carry from here is the general form: a section can have a size and occupy no file bytes, because the size describes memory and the offset describes the file, and for zero-fill data those are different quantities. A reader that copies size bytes from offset is correct for every section except that one, and the exception is the one that appears in almost every object file ever produced.
One last connection, and it is the one that matters for the mission. A code generator chooses section names, and section names are how a linker grants permissions. Put a writable object in a section a reader expects to be read-only and you have changed the security properties of the output; put code in a section named like data and the linker may place it in a data segment and the program will fault on execution. That is not a hypothetical class of bug — it is why the Position-Independent Code and hardening courses exist, and it is why a compiler author has to understand the section table as a contract with the linker rather than as a filing system.