Back to course

Where Names Live

17 minintermediateModule 2: Anatomy, ComparedContinues from obj-symbols
17 min · Module 2: Anatomy, Compared · Intermediate

Why This Matters

The two previous concepts kept arriving at the same subject from different directions. Sections compared three ways to store a section name: an offset, an 8-byte field, a 16-byte field. Symbols compared three ways to store a symbol name: an offset, an 8-byte union, an offset into a per-segment table. Both tables are about the same design decision, and a linker that gets it wrong cannot read a single name in the file.

It is also the decision with the widest blast radius. A section name read wrongly merges two sections. A symbol name read wrongly resolves a reference to the wrong target. And in both cases the file still parses, the sizes still add up, and the error surfaces as a program that links and then misbehaves.

So this concept is short and specific: three storage strategies, the escape hatch each one needs, and the one question that explains why ELF has two string tables and the others have one.

A Simple Model

Names appear in a class file in two independent roles, and the strategies differ per role:

ELF section name -> u32 OFFSET into the table e_shstrndx names symbol name -> u32 OFFSET into .strtab TWO tables, because a section header and a symbol are walked at different times by different tools and neither may depend on the other. COFF section name -> 8 bytes INLINE ... or "/NNN" meaning decimal offset NNN into the string table symbol name -> 8 bytes, first 4 zero means last 4 are a string-table offset ONE table, serving three jobs: long section names, long symbol names, and (in the PE world) export and import names. Mach-O section name -> 16 bytes INLINE symbol name -> u32 OFFSET into the __stringtab OF THE SYMBOL'S OWN SEGMENT ONE table per segment, plus a segment lookup before you can read a single name.

Three strategies, and only one of them is an offset into a table you must find. That is the summary; the rest of this concept is why each format chose what it chose, and what it costs.

The Actual Detail

All measured on this course's three specimens. Starting with COFF, because it uses one table for everything and so shows the escape hatch most clearly.

  COFF, demo_coff.o. Symbol table at 0x211, 26 records
  of 18 bytes, so the string table is at:

      0x211 + 26 * 18  =  0x3e5

  The name field is 8 bytes and it is a UNION:

      first 4 bytes NON-ZERO  ->  the 8 bytes ARE the name
          b'.text\x00\x00\x00'
          b'@feat.00'
          b'compute'
          b'.debug$S'      exactly 8 characters, no NUL

      first 4 bytes ZERO      ->  the last 4 are a DECIMAL
                                  offset into the string table
          strtab+4   -> "message_bytes"
          strtab+18  -> "global_counter"
          strtab+33  -> "external_fn"
          strtab+45  -> ".llvm_addrsig"
          strtab+59  -> "uninitialised"
          strtab+73  -> "??_C@_05CJBACGMB@hello?$AA@"

One table, three consumers, and the reuse is the interesting part. The same string table at 0x3e5 serves long symbol names and the long section-name escape from the previous concept — strtab+45 is .llvm_addrsig, and section 6's name field is the eight bytes /45 pointing at the same string. A reader that has already walked the string table for section names can walk it again for symbols at no cost.

And the arithmetic that trips everyone: /45 is decimal 45, not 0x45. Decimal 45 is offset 0x2d from the table start and gives .llvm_addrsig. Hexadecimal 0x45 is 69 and lands in the middle of the symbol names, giving garbage. The specification says decimal and the specification is right, because COFF's string-table offsets have always been printed in decimal by every tool that dumps one.

Mach-O: an offset, but into which table?

Mach-O's symbol names are 4-byte offsets, which looks like ELF until you ask offset into what. The answer is the segment, and that is a real complication:

  Mach-O nlist_64, 16 bytes:

      n_strx   u32   offset into a string table
      n_type   u8    N_STAB | N_TYPE | N_EXT -- the type
      n_sect   u8    section index, 0 = no section
      n_desc   u16   library ordinal, or a visibility
      n_value  u64   the address

  and the string table is PER SEGMENT. In demo_macho.o
  there is one LC_SEGMENT_64 and it is UNNAMED, with
  nsects = 6, and the per-section segname fields say
  __TEXT or __DATA.

  So to read _compute's name you must:
      1. find which SEGMENT the symbol's section is in
      2. find that segment's __stringtab
      3. follow n_strx into it

  a two-step lookup where ELF and COFF are one step.

And that is the price of the smallest symbol record in the format. 16 bytes against ELF's 24, achieved partly by not storing a name and partly by making the name's location derivable rather than declared. Smaller records, more work per record — the same trade the section table made, where Mach-O's 80-byte header bought a name at the cost of sixteen bytes per section for a segment name that is inert in an object file.

ELF: one offset, two tables

And ELF, which is the simplest per-record and the most complex per-file:

  Elf64_Shdr, 64 bytes:

      sh_name      u32   offset into the table e_shstrndx names
      sh_type      u32   SHT_PROGBITS, SHT_RELA, ...
      sh_flags     u64   SHF_WRITE | SHF_ALLOC | SHF_EXECINSTR
      sh_addr      u64   0 in an object
      sh_offset    u64   where the bytes are
      sh_size      u64   how many
      sh_link      u32   for .rela: the symtab index
      sh_info      u32   for .rela: the target section index
      sh_addralign u64   A BYTE COUNT (16, not 4)
      sh_entsize   u64   24 for Elf64_Rela

  Elf64_Sym, 24 bytes:

      st_name      u32   offset into .strtab
      st_info      u8    binding | type << 4
      st_other     u8    visibility
      st_shndx     u16   section index, or a reserved value
      st_value     u64   offset -- or an ALIGNMENT, for COMMON
      st_size      u64   size

Four bytes for a name, in both tables, and nothing inline. The cost is that the name lives somewhere other than the record that uses it, so every lookup is a second step — and ELF has two slots for those second steps, named by e_shstrndx and by a symbol table's sh_link. They are usually different indexes, and the specimen in this course shows they are not required to be.

So it is worth measuring rather than repeating, because a claim that is in a great many textbooks turns out not to be what this file says. demo_elf.o has one string table, not two. e_shstrndx is 1 and the .symtab's sh_link is also 1, and section 1 is named .strtab — there is no .shstrtab section header in the file at all. The single table's contents make the merge obvious: it interleaves section names and symbol names, and nothing in it says which is which. GCC's build of the same 30 lines does have two, with e_shstrndx = 16 and sh_link = 15. Both files are conforming, because the specification says section names come from the table named by e_shstrndx and never says that table has to be distinct from .strtab or has to be called .shstrtab.

The reason is ordering. A section header is needed by the linker to lay out the file; a symbol is needed to resolve names. A tool that lists sections should not have to parse the symbol table, and a tool that resolves symbols should not have to read section headers — some symbol operations, like nm on a stripped object, work fine with no section information at all. One shared table would make each of those tools depend on the other's data structure, and ELF's designers decided that was the worse trade. COFF and Mach-O, with their smaller records, made the opposite call — and Mach-O's per-segment version is that call's logical endpoint.

A Real Example

What each choice costs, in bytes and in complexity, measured against the same three files:

BYTES PER NAME ELF 4 bytes, always. 16 section names + 12 symbol names = 112 bytes of pointers, plus two tables (217 + ~180 bytes here) whose content is the strings plus one leading NUL each. COFF 8 bytes, usually with the string inline and no table at all. 7 sections + 14 symbols = 168 bytes of record space, of which most is the name itself. Table only for the overflow. Mach-O 16 bytes per SECTION (32 counting the segment name), 4 bytes per SYMBOL. The section cost is the highest of the three and buys a fixed field with no lookup at all. THE COMPARISON THAT MATTERS a linker resolves every symbol reference in the program. That is O(references), and for each one it needs a name. So the hot path is: symbol -> name. ELF one memory read into a table, plus a bounds check COFF one branch (inline or not), usually a read of 8 bytes already in the record Mach-O a segment lookup, then a table read COFF's is the fastest and Mach-O's the slowest, for the same conceptual work.

So the fastest reader is the one with the largest records, which is the trade in one sentence: COFF spends bytes per record to buy speed per lookup, and Mach-O spends lookups per record to buy bytes. Both are rational, and the choice is a bet about which resource is scarrier in the workload you care about — and about what the format's author thought a linker's bottleneck was.

That last point is the one worth teaching, because it recurs in every format. A format's redundant-looking fields are usually a bet about a bottleneck.COFF keeps 40-byte section headers with 20 bytes of always-zero VirtualAddress because it inherited the layout from an executable format and the 20 bytes cost nothing; ELF's 64-byte header is 24 bytes larger than it needs and buys an architecture plugin point; Mach-O's reserved fields are the residue of a design that once used them. Reading a format's waste is reading its history, and this course's whole approach — measure, then ask why — is that principle applied.

One more measured detail that ties the two tables together, because it is the kind of thing that makes the design click. Every string table begins with a single NUL byte, so offset 0 means the empty string. That is not a coincidence or a padding convention: it means a zero st_name or sh_name is a legal, meaningful value meaning "no name", and a reader never has to special-case zero. The table's first byte is reserved for the one case where the pointer is zero. It is a tiny piece of engineering that removes a branch from every name lookup in the program, and it is the kind of detail that only shows up when you actually dump the bytes.

Try It Yourself

$ python3 -c "print(open('demo_coff.o','rb').read()[0x3e5:0x3e5+24])"
$ llvm-readobj-21 --string-table demo_macho.o

Check Your Understanding

Without looking back: a COFF section header's 8-byte name field contains the eight bytes /45\x00\x00\x00\x00\x00. A reader that treats the field as inline text sees the name /45. What is the real name, what is the arithmetic to get it, and why is it a decimal offset rather than a hexadecimal one?

Apply It

You are writing a symbol-name lookup for a linker and it must handle all three formats. It is a single function with a switch on the format, and it works on ELF and COFF. On Mach-O it returns a name for about half the symbols and an empty string for the rest, and the link fails with unresolved references to symbols that nm lists. The code reads n_strx and looks it up in the string table it found earlier. What is wrong, and what is the shape of the fix that does not turn the function into three functions?

The general habit: a value read from a file is not a value until you have decided what it means. Resolve format-dependent context at the parse boundary and store the answer, so every consumer downstream is format-blind — because there is always more than one consumer.

Connect

This concept is the shared subject of the two before it, and saying so is the point. Sections Compared found the three name-storage strategies for section headers; Symbol Tables Compared found them again for symbols, plus COFF's union field and ELF's alignment-repurposing. Seeing the same three decisions twice, in two unrelated tables, is what turns them from trivia into a design axis — and the axis is exactly "inline field versus table offset", which every container format in existence has to place on the spectrum somewhere.

The ELF two-table decision has a close relative in a format you have met, and it is the same argument. The JVM constant pool is a single table that both class names and string literals live in, and it works because a class file is read as a whole by one tool. ELF's two tables exist because an object file is read by many tools, each wanting a different part — a linker, a disassembler, nm, a stripper, a debugger. The more consumers a format has, the more it pays to keep their dependencies separate. That is a real scaling argument, and it explains why a format designed for one tool can afford one table while a format designed for an ecosystem cannot.

Mach-O's per-segment string table connects to something that is not a name at all. Mach-O's load commands are a flat list in which segments and sections are nested, and the string tables hang off that structure. So the segment lookup that costs a Mach-O reader a second indirection is not an extra feature — it is a consequence of the format's whole organising principle being a tree of load commands rather than a flat header plus tables. The same design choice shows up in the name lookup, and that is what makes it worth naming: a format's internal structure determines the cost of its lookups.

And the "read the waste to read the history" point connects to the ELF course's section-header walk and to PE's always-zero VirtualAddress fields in object files. In every case a field exists because the format was extended from a simpler form, and in every case the residue tells you which form. That is a genuinely useful diagnostic when you meet an unfamiliar format: the fields that seem to serve no purpose are the ones that tell you what the format grew out of, and therefore what other formats it is likely to interoperate with.

That closes Module 2. The object file is now open: sections, symbols, names, and the storage of uninitialised data — four concepts on the four structures a linker reads, each one compared across three formats, and each one turning up a case where a field does not mean what its name suggests. Module 3 goes back to the fixup and takes it apart: the relocation record field by field, where the addend turns out to live in the bytes rather than the record, position independence turns out to be a choice of relocation set, and forty-four types on one architecture turn out to be a classification rather than a list.