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.
Names appear in a class file in two independent roles, and the strategies differ per role:
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.
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'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.
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.
What each choice costs, in bytes and in complexity, measured against the same three files:
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.
$ python3 -c "print(open('demo_coff.o','rb').read()[0x3e5:0x3e5+24])"
$ llvm-readobj-21 --string-table demo_macho.o0x211 + 26 * 18 = 0x3e5. Section 6's name field says /45. Read offset 45 decimal, then 0x45, and compare. The first gives .llvm_addrsig and the second gives binary garbage. Doing it in the wrong order once is the point — it is why the concept says decimal so loudly.e_shstrndx from the ELF header of demo_elf.o, then read the .symtab header's sh_link. They are both 1, and section 1 is named .strtab — so the file has one table, not two. Dump those 217 bytes and you will find section names and symbol names interleaved with nothing to tell them apart. Then do the same two reads on demo_elfgcc.o, where the indexes are 16 and 15 and the tables really are separate, and confirm that every reader accepts both files. That is the whole lesson: the index is specified, the name is a convention, and only one of the two is safe to rely on.demo_elf.o's e_shstrndx points at. It is zero, and offset 0 is therefore the empty string — which is what makes a zero st_name legal rather than an error. Then find a symbol whose st_name really is 0 and confirm what it means: in this file that is the reserved entry and the two STT_SECTION symbols, whose names readers substitute from the section table rather than read from a string at all.__stringtab, follow n_strx. Then delete the segment lookup and watch it work on ELF and fail on Mach-O — which is the clearest demonstration of why that format is harder to read./NNN is one of the oldest and cleanest. The pattern — fixed field, overflow escape, shared table — recurs in nearly every container format and is worth recognising on sight.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?
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.
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.