Back to course

The Symbol Table

20 minadvancedModule 2: Symbols and RelocationsContinues from coff-string-table
20 min · Module 2: Symbols and Relocations · Structures

Why This Matters

Relocations reference symbols by index, not by name. Symbol 23 is the thing relocation 0 wants the address of. So the symbol table is the vocabulary every relocation in the file is written in, and a reader that mis-parses it produces relocations pointing at the wrong thing.

It is also the only place in a COFF object that records a section's final size in memory, because the section header's memory fields are all zero. That is a strange responsibility for a symbol table, and it is why the section-definition records inside it matter more than they look.

A Simple Model

Eighteen bytes per record, six fields, and one of them is a union with the string table:

  1. Name (8 bytes) — inline, or a string-table offset. Resolved in the previous concept.
  2. Value (4 bytes) — the symbol's offset within its section. This is the one address a compiler can know, because the section is its own private space for now.
  3. SectionNumber (2 bytes) — 1-based index into the section table. 0 means undefined; 0xFFFE means absolute.
  4. Type (2 bytes) — mostly 0, with 0x20 for a function and 0x0 for data.
  5. StorageClass (1 byte) — the field that actually carries meaning. In practice almost everything is 2 (external) or 3 (static).
  6. NumberOfAuxSymbols (1 byte) — how many extra 18-byte slots follow.

That last field is the one that catches people. NumberOfSymbols counts slots, and a symbol with one aux record takes two. A reader that advances by one record per symbol desynchronises at the first aux record and never recovers.

The Actual Detail

Our file has NumberOfSymbols = 31 slots at 0x348. The first three records, raw:

00000348: 2e74 6578 7400 0000 0000 0000 0100 0000  .text...........
00000358: 0301 6c00 0000 0300 0000 bcbb d609 0100  ..l.............
00000368: 0000 0000 2e64 6174 6100 0000 0000 0000  .....data.......
00000378: 0200 0000 0301 3000 0000 0200 0000 27a4  ......0.......'.
00000388: dad5 0200 0000 0000                      ........

Read the rows as three 18-byte records. Slot 0 is at 0x348, slot 1 at 0x35A, slot 2 at 0x36C:

SlotAtNameValueSectionTypeStorageAux
00x348.text0103 STATIC1
10x35Athe aux record — not a symbol0
20x36C.data0203 STATIC1

Slot 0 field by field, from the bytes:

OffsetBytesFieldValue
+02e 74 65 78 74 00 00 00Name.text, inline
+800 00 00 00Value0
+1201 00SectionNumber1
+1400 00Type0
+1603StorageClass3 = STATIC
+1701NumberOfAuxSymbols1 — one record follows

Which is llvm-readobj's: Name: .text, Value: 0, Section: .text (1), StorageClass: Static (0x3), AuxSymbolCount: 1. And the symbol is not at 0x350 — the next real symbol, .data, is at 0x36C. The 18 bytes in between are its aux record. This is the arithmetic that trips readers up: slots 0 and 1 are .text and its aux; slot 2 is .data.

What the aux record actually contains

The section-definition aux record is 18 bytes, and its field order is not the one the specification documents. Here are the bytes for .text:

6c 00 00 00 | 03 00 00 00 | bc bb d6 09 | 01 00 | 00 | 00 | 00 00
 Length         Relocations      CheckSum        Number  Sel   rsvd  Lines

Read in the order the specification gives — Length, NumberOfRelocations, NumberOfLinenumbers, CheckSum, Number, Selection — and you get NumberOfLinenumbers = 0x09D6BBBC = 165,067,708. Absurd. Read it the way the compiler actually emits it, and every field is sensible:

OffsetSizeField.textllvm-readobj
0x004Length108Length: 108
0x044NumberOfRelocations3RelocationCount: 3
0x084CheckSum0x09D6BBBCChecksum: 0x9D6BBBC
0x0c2Number1Number: 1
0x0e1Selection0Selection: 0x0
0x102NumberOfLinenumbers0LineNumberCount: 0

There is independent proof of this ordering that does not depend on believing any tool. The Number field at 0x0c is defined as the section's 1-based index. Check it against the section number in the owning symbol, for every aux record in every sample:

Symbol's sectionNumber at 0x0cMatch
1 (.text)1yes
2 (.data)2yes
3 (.bss)3yes
4 (.xdata)4yes
5 (.rdata)5yes
6 (.rdata, COMDAT)6yes

All 58 aux records across the five sample objects satisfy that invariant. A layout that placed NumberOfLinenumbers at 0x08 could not produce this coincidence 58 times running. This is the strongest form of evidence available when a specification and a compiler disagree: find a field whose value is constrained by something else in the file, and check it.

And note the field this settles the earlier question: .bss's aux record says Length = 4, even though its section header says VirtualSize = 0 and SizeOfRawData = 4. The size .bss will occupy in the linked image is recorded here and nowhere else in the object.

A Real Example

Beyond the section symbols, the ones that matter to relocations. These are the indices relocation records actually reference:

SlotNameValueSectionStorage
0.text01STATIC
2.data02STATIC
4.bss03STATIC
6.xdata04STATIC
8.rdata05STATIC
10.rdata (COMDAT)06STATIC
12??_C@_03PLHFFLIH@ptr?$AA@06EXTERNAL
15.pdata08STATIC

And the named symbols further down, which is where Value starts to mean something:

NameValueSectionTypeStorage
add01 .text0x0020 functionEXTERNAL
call_through321 .text0x0020 functionEXTERNAL
use801 .text0x0020 functionEXTERNAL
g_data02 .data0EXTERNAL
g_addr162 .data0EXTERNAL
table322 .data0EXTERNAL
g_str05 .rdata0EXTERNAL

use at 80 and table at 32 are the two numbers worth pausing on, because they are the whole "known offset, unknown address" idea made concrete. use is 80 bytes into .text — the compiler knows that exactly, because it laid the section out itself. What it cannot know is what .text's address will be after the linker concatenates four object files. So the symbol says "80 bytes into section 1", and the relocation says "add the final address of section 1 to this".

call_through at 32 and use at 80 are 48 bytes apart, which is the compiled size of call_through — you can measure it directly. And add at 0 through 32 is the other half of the 108-byte .text: 32 for add, 48 for call_through, 20 for use, 8 of padding. The symbol table is a map of the section, and the numbers fit.

Also note s_data is absent from the named list. It is static, so it is emitted as a STATIC symbol at the end of the table, and it has no linker-visible name. That is the difference between storage class 2 and 3 doing real work: EXTERNAL symbols are the ones other object files can refer to, STATIC ones are private to this file.

Try It Yourself

$ clang --target=x86_64-pc-windows-msvc -c sample.c -o sample_msvc.obj
$ llvm-readobj --symbols sample_msvc.obj | head -40
$ xxd -s 0x348 -l 72 sample_msvc.obj

The exercise worth doing is the one that proves the aux record ordering, because it is the kind of check that catches a wrong assumption permanently:

And to see the three dialect differences in the same table:

$ llvm-readobj --symbols sample_m32.obj | grep -A2 'Name: add'
    Name: _add          # i386: leading underscore
$ llvm-readobj --symbols sample_msvc.obj | grep -A2 'Name: add'
    Name: add           # x64: no underscore

Check Your Understanding

Without looking back: you are walking the symbol table and reach slot 1. What is there, and what mistake have you made if you tried to read it as a symbol?

Apply It

Your linker is asked for the size of .bss in the object it is about to place. It reads the section header, finds VirtualSize = 0, and allocates nothing. The program then corrupts memory at startup. What is the correct source for that size, and why is the section header actively misleading here?

The general habit: a field that is zero because of a container-level invariant is not the same as a field that is zero because the thing is empty. Before trusting a size, find out whether the zero is meaningful or merely structural.

Connect

A symbol's Value is an offset within its section, and the section table is what gives that section meaning. That is the same two-step indirection as a DWARF DW_AT_type reference, and the same lesson: the DWARF file tables resolve a number to a name through a table, and COFF resolves an address to a symbol through one.

The COFF storage classes are the direct ancestor of the ELF ones you already know. STATIC here is STB_LOCAL in ELF, EXTERNAL is STB_GLOBAL, and the ELF visibility concept covers what happens when they meet at link time.

Now the table that consumes all of this. A symbol is a name and a place; a relocation is a question about a byte.

Next: Relocations — ten bytes each, and a second place where the specification and the compiler disagree.