Back to course

The String Table

15 minintermediateModule 2: Symbols and RelocationsContinues from coff-characteristics
15 min · Module 2: Symbols and Relocations · Structures

Why This Matters

A C symbol is a handful of characters. A C++ symbol is a hundred or more. A section name is usually short, but not always. COFF gives every name exactly eight bytes of room and then runs out.

The string table is the overflow, and it is also the place where two different name fields in two different structures both end up. Understanding it means being able to resolve a symbol's name and a section's name from the same pool — which is exactly what a linker does, and what you will do when you read one of these files.

A Simple Model

Three facts, and they compose into everything:

  1. The string table is not pointed at by any header field. Its position is implied: it starts at PointerToSymbolTable + NumberOfSymbols × 18. There is no offset to read.
  2. Its first four bytes are its own total size, including those four bytes. That is how you know where it ends, and therefore where the file ends.
  3. Every string is NUL-terminated, and every reference to one is a 4-byte offset from the start of the table — so the smallest valid offset is 4, not 0.

Point 3 is the one to hold on to. Offset 0 is the size field, not a string, which is why no real name ever has offset 0 and why a name of "0" is a bug rather than a valid reference.

The Actual Detail

Our file's string table, in full. 57 bytes, at file offset 0x576, running to the last byte of the file:

00000576: 3900 0000 6361 6c6c 5f74 6872 6f75 6768  9....call_through
00000586: 002e 6c6c 766d 5f61 6464 7273 6967 003f  ..llvm_addrsig.?
00000596: 3f5f 4340 5f30 3350 4c48 4646 4c49 4840  ?_C@_03PLHFFLIH@
000005a6: 7074 723f 2441 4140 00                    ptr?$AA@.

Split it:

And the arithmetic closes exactly, which is the check that the table is fully understood:

table starts at 0x348 + 31 × 18 = 0x576
declared size 57 = 0x39
table ends at 0x576 + 57 = 0x5AF
file size 1455 = 0x5AF <- MATCH

Byte 16 is the NUL that ends call_through; byte 17 starts the next string. Byte 30 ends .llvm_addrsig; byte 31 starts the mangled name. Nothing is padded, and nothing is wasted beyond the one NUL after each string.

The 8-byte name union

Both the symbol record and the section header begin with the same eight-byte union:

if bytes 0-3 are 00 00 00 00: bytes 4-7 = 4-byte offset into the string table
otherwise: the 8 bytes ARE the name, NUL-padded

Two verified examples from the same file:

Section 9's Name field is not a name and not an offset — it is the text/17:

00000154: 2f31 3700 0000 00                          /17.....

Slash, then the decimal digits 17. String-table offset 17 is .llvm_addrsig, which is exactly what llvm-readobj reports. Note that this is a different encoding from the symbol union: a leading slash plus ASCII decimal, not a zero dword plus a binary offset. Two conventions, both landing in the same pool.

Symbol 12's Name field uses the zero-dword form:

symbol 12 at 0x348 + 12*18 = 0x420:
00000420: 0000 0000 1f00 0000                       ........
                 |______| |______|
              zero dword  offset = 31

Offset 31 is ??_C@_03PLHFFLIH@ptr?$AA@ — the MSVC-mangled name of the "ptr" string literal, which is 25 characters and cannot fit in eight bytes. Every other name in this file is short enough to be inline, which is why this is the only symbol in the file that uses the pool.

A Real Example

Three names, three mechanisms, one pool. This is the whole idea of the concept in a single table:

NameWhere it livesEncodingResolves to
.textinline, section 1 header8 bytes, NUL-paddeditself, no lookup
.llvm_addrsigtable offset 17/17 in the name fieldoffset 17
??_C@_03PLHFFLIH@ptr?$AA@table offset 31zero dword + offsetoffset 31

Now decode all three from the file by hand and confirm with the tools:

$ xxd -s 0x154 -l 8 sample_msvc.obj     # section 9 name
00000154: 2f31 3700 0000 00                        /17.....

$ xxd -s 0x420 -l 8 sample_msvc.obj     # symbol 12 name
00000420: 0000 0000 1f00 0000                    ....

$ xxd -s 0x576 -l 57 sample_msvc.obj     # the table itself
00000576: 3900 0000 6361 6c6c 5f74 6872 6f75 6768  9....call_through
...

$ llvm-readobj --sections sample_msvc.obj | grep llvm_addrsig
    Name: .llvm_addrsig

$ llvm-readobj --symbols sample_msvc.obj | grep PLHFFLIH
    Name: ??_C@_03PLHFFLIH@ptr?$AA@

Three reads, three mechanisms, one correct answer each. The mechanism differs every time; the pool does not.

One detail about the mangled name is worth a sentence, because it is a portability trap rather than a COFF one. ??_C@_03PLHFFLIH@ptr?$AA@ is a Microsoft-specific encoding of the C++ symbol for the string literal "ptr" — the ??_C@ prefix marks a string-literal symbol and the rest is a hash and a length. The GNU-dialect build of the same source does not produce it at all; it has eight sections rather than nine, because it did not merge the string literals into a separate COMDAT .rdata. The container is identical; the dialect decides what is in it.

Try It Yourself

$ clang --target=x86_64-pc-windows-msvc -c sample.c -o sample_msvc.obj
$ xxd -s 0x576 sample_msvc.obj          # the whole string table, 57 bytes
$ llvm-objdump -h sample_msvc.obj | tail -4

Three things to confirm, in order:

To see the growth mechanism for yourself, compile something with a long C++ name:

$ clang++ --target=x86_64-pc-windows-msvc -c comdat.cpp -o comdat.obj
$ llvm-objdump -h comdat.obj | head -6
$ llvm-readobj --symbols comdat.obj | grep -c 'Name:'
$ llvm-readobj --symbols comdat.obj | grep 'Name: .*::' | head -3

Look at how many names in the C++ object are long, and how the table grows to hold them while the container stays the same 40- and 18-byte records.

Check Your Understanding

Without looking back: a symbol's eight-byte name field is 00 00 00 00 1F 00 00 00. What is the symbol called, and how do you get there?

Apply It

Your COFF reader wants to print a section name. It reads the eight-byte Name field, checks whether the first dword is zero, and if not strips the NUL padding and prints it. On sample_msvc.obj it prints llvm_addr for section 9. What is wrong, and what are the two rules your resolver needs?

The general habit: when a format reuses one eight-byte slot for several kinds of value, the discriminator is a convention, not always a field. Write the three cases down explicitly, or you will ship a resolver that works on every name except the interesting ones.

Connect

Offset-not-content string pools are the same idea as DWARF's DW_FORM_strp and DW_FORM_line_strp in the DWARF file tables concept — one copy of each name, referenced by number, so the linker can merge identical pools across object files. That is why .debug_str is flagged SHF_MERGE in ELF and why the COFF string table is a prime candidate for the same treatment.

The 8-byte inline-or-offset union is also how ELF's sh_name works in spirit, though ELF uses a 4-byte index into a dedicated string table rather than overloading one field. COFF's approach is older and more economical on space, at the cost of the three-way ambiguity you just hit.

Now that names can be resolved, the table they belong to can be read. Module 2 continues with the structure that uses them most.

Next: The Symbol Table — 18 bytes a record, and an aux record whose field order is not what the specification says.