Seven fields in twenty bytes, and every parser needs all seven before it can read anything else. NumberOfSections says how long the section table is. PointerToSymbolTable says where the symbols are. SizeOfOptionalHeader says whether this file is an object or an image.
It is a small structure, which means it is easy to get subtly wrong in ways that do not announce themselves. Two of the seven fields are 16-bit and two are 32-bit, the section table starts immediately after, and there is no magic number to catch you.
Read it as three questions, in this order:
Machine and SizeOfOptionalHeader. The first says which CPU; the second says object or image.NumberOfSections, PointerToSymbolTable, NumberOfSymbols.TimeDateStamp and Characteristics.The order matters for one specific reason: SizeOfOptionalHeader is how you know whether the section table starts at offset 20 or at offset 20 + SizeOfOptionalHeader. In an object it starts at 20. In a PE image it starts after the optional header. Get this wrong and every section header you read is garbage.
All seven fields, decoded from the bytes of sample_msvc.obj:
| Offset | Size | Field | Bytes | Value | Meaning |
|---|---|---|---|---|---|
| 0x00 | 2 | Machine | 64 86 | 0x8664 | IMAGE_FILE_MACHINE_AMD64 |
| 0x02 | 2 | NumberOfSections | 09 00 | 9 | section table has 9 records |
| 0x04 | 4 | TimeDateStamp | 2a a5 b6 6a | 0x6AB6A52A | seconds since the Unix epoch |
| 0x08 | 4 | PointerToSymbolTable | 48 03 00 00 | 0x348 | file offset of the symbol table |
| 0x0c | 4 | NumberOfSymbols | 1f 00 00 00 | 31 | 18-byte records, aux included |
| 0x10 | 2 | SizeOfOptionalHeader | 00 00 | 0 | no optional header: this is an object |
| 0x12 | 2 | Characteristics | 00 00 | 0 | none set |
Every one of those values was decoded independently from the raw bytes and then required to match llvm-readobj --file-headers, which reports SectionCount: 9, PointerToSymbolTable: 0x348, SymbolCount: 31 and OptionalHeaderSize: 0. Agreement was required before the table above was written down.
Three fields deserve more than a table row.
NumberOfSymbols counts aux records. It is a count of 18-byte slots, not a count of names. A symbol that owns one aux record occupies two slots. Our file has 31 slots and, once you skip past the aux records, far fewer actual names. A parser that treats 31 as "31 symbols" will read 18 bytes of garbage as a symbol and then lose sync. The rule is: read a record, read its NumberOfAuxSymbols, advance by 1 + NumberOfAuxSymbols slots.
TimeDateStamp is a Unix timestamp in an otherwise unremarkable little-endian file. Ours is 0x6AB6A52A. This is not a COFF invention — it is the same epoch as the ELF header, and the same field you saw in the ELF course's own hex dumps. Cross-format consistency is rarer than you would expect, and worth noticing when it happens.
Characteristics is zero here, and that is normal for an object. This field exists for images, where it carries IMAGE_FILE_EXECUTABLE_IMAGE and IMAGE_FILE_DLL. An object is neither, so it sets none of them. Do not read its emptiness as a missing field.
Machine is the field that tells you how wide a pointer is, which changes how you interpret almost everything else. The three targets used in this course:
| Value | Name | Pointer size | Seen in this course |
|---|---|---|---|
| 0x014C | IMAGE_FILE_MACHINE_I386 | 32-bit | sample_m32.obj |
| 0x8664 | IMAGE_FILE_MACHINE_AMD64 | 64-bit | sample_msvc.obj, sample_gnu.obj |
| 0xAA64 | IMAGE_FILE_MACHINE_ARM64 | 64-bit | the PE course's cli-64.exe family |
Others you will meet in the PE course include 0x01C4 (ARMNT, 32-bit ARM), 0x0200 (IA64, Itanium) and 0xAA64 (ARM64). llvm-readobj prints the address size alongside the machine, which is the quickest sanity check available: a file claiming AMD64 with AddressSize: 32bit is lying to you about something.
Decode the header by hand, then check. Here are the first 20 bytes again, this time with the reading written under each field:
00000000: 6486 0900 2aa5 b66a 4803 0000 1f00 0000
|---| |--| |----| |--------| |--------|
0x8664 9 ts 0x348 31
00000010: 0000 0000
|--| |--|
opt=0 ch=0Now the check that proves the field boundaries are right rather than merely plausible. NumberOfSections is 9, so the section table is 9 records of 40 bytes = 360 bytes, running from 0x14 to 0x17C. And 0x17C is exactly where the .text raw data begins, according to the first section header:
$ xxd -s 0x14 -l 4 sample_msvc.obj
00000014: 2e74 6578 .tex
$ xxd -s 0x17c -l 8 sample_msvc.obj
0000017c: 50 00 00 00 00 00 00 00 P.......
$ llvm-readobj --sections sample_msvc.obj | head -8
Number: 1
Name: .text (2E 74 65 78 74 00 00 00)
RawDataSize: 108
PointerToRawData: 0x17C0x14 + 360 = 0x17C. Two independently derived numbers — the section count times the record size, and the raw data pointer read out of the first section header — land on the same offset. That is what a correct header looks like.
Do the same arithmetic for the symbol table: PointerToSymbolTable = 0x348 and NumberOfSymbols = 31, so 31 × 18 = 558 = 0x22E, putting the end at 0x348 + 0x22E = 0x576. And 0x576 is where the string table starts — which is where the file utility's symbol offset=0x348 was pointing all along.
$ clang --target=x86_64-pc-windows-msvc -c sample.c -o sample_msvc.obj
$ xxd -l 20 sample_msvc.obj
$ llvm-readobj --file-headers sample_msvc.obj
$ file sample_msvc.objConfirm the three cross-checks, because they are the whole habit of this concept:
0x14 + NumberOfSections × 40 should equal the PointerToRawData of section 1. For us: 0x14 + 360 = 0x17C.PointerToSymbolTable + NumberOfSymbols × 18 should be where the string table begins. For us: 0x348 + 558 = 0x576.SizeOfOptionalHeader should be 0, and byte 0x14 should be the . of .text. If it is not, you are reading the wrong file or the header is corrupt.Then see the machine field actually matter:
$ clang --target=i686-pc-windows-msvc -c sample.c -o sample_m32.obj
$ llvm-readobj --file-headers sample_m32.obj | grep -E 'Machine|AddressSize'The same source now reports IMAGE_FILE_MACHINE_I386 (0x14C) and AddressSize: 32bit. Nothing about the container changed; the CPU did.
Without looking back: NumberOfSymbols is 31 and PointerToSymbolTable is 0x348. Where does the symbol table end, and what begins there?
You are writing a tool that must handle both COFF objects and PE images. You read NumberOfSections = 9 and compute that the section table starts at 0x14. On one file that is right; on another every section header is garbage. What is the one field you forgot to consult, and what is the correct rule?
The general habit: a format that has two variants almost always encodes the discriminator as a size. Prefer computing positions from the file's own numbers over hardcoding them, and the tool works on both variants for free.
The IMAGE_FILE_HEADER here is byte-for-byte the structure at offset 0 of a PE executable, which the PE course decodes. Reading the two side by side is the fastest way to see what "PE is COFF plus an optional header" actually means in bytes.
NumberOfSections is a 16-bit field, which is exactly why the format has a second variant, bigobj, for files that would exceed its limits. Module 1 closes with a look at that, and at the one place where a COFF field is more subtle than it appears — the section characteristics bitfield.
Next: The Section Header — ten fields, forty bytes, and a section that has no bytes in the file at all.