Back to course

Sections, Compared

21 minintermediateModule 2: Anatomy, ComparedContinues from obj-no-segments
21 min · Module 2: Anatomy, Compared · Intermediate

Why This Matters

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.

A Simple Model

What a section has to answer, and then the three ways of answering it:

a section must be able to say: what is it called a NAME what kind of thing is it a TYPE (code? data? relocations?) where are its bytes an OFFSET into the file how many bytes a SIZE what must its start align an ALIGNMENT what may be done to it FLAGS (writable? executable?) ELF 64-byte Elf64_Shdr, name = u32 OFFSET into the table e_shstrndx names COFF 40-byte, name = 8 bytes INLINE Mach-O 80-byte section_64, name = 16 bytes INLINE

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.

The Actual Detail

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.

Two sections are both named .rdata

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.

Record [6] is /45 -- a long name, escaped

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.

Mach-O, and the alignment trap

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.

Where the section names come from

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.

So the rule to take away is not a name, it is an index.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.

A Real Example

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:

1. GROUP concatenate same-named sections from every input. Needs: the name. ELF follows e_shstrndx. COFF compares 8 bytes or follows /NNN. Mach-O compares 16. 2. ORDER place them. Needs: nothing -- the linker chooses, using alignment and its own policy. 3. ALIGN advance the output cursor to satisfy the next section. Needs: the alignment, in the right UNITS (this is the trap). 4. SIZE know how many bytes to copy. Needs: size. Watch for .bss and __common, where a nonzero size has offset 0 -- nothing to copy. 5. RELOCATE find this section's fixups. Needs: COFF keeps a pointer+count in the section header; ELF keeps a SEPARATE .rela.* section; Mach-O keeps pointer+count in the record. 6. PERMIT decide read-only vs writable. Needs: the type or flags, and then the linker must put every .rodata in the same group -- which only works because the name is a reliable key.

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.

The general shape of both traps is the same, and it is worth naming as a habit. A .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.

Try It Yourself

$ llvm-readobj-21 --sections demo_coff.o
$ readelf -SW demo_elf.o
$ llvm-objdump-21 -h demo_macho.o

Check Your Understanding

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?

Apply It

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.

Connect

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.