Back to course

COMDAT and Duplicate Sections

19 minadvancedModule 2: Symbols and RelocationsContinues from coff-relocations
19 min · Module 2: Symbols and Relocations · Linker Semantics

Why This Matters

Write the same inline function in a header, include that header in four source files, compile them separately, and link them. You now have four copies of the same function. Keep them all and you get four definitions of one symbol. Throw them away arbitrarily and you can discard the copy a relocation actually points at.

COMDAT is the mechanism that resolves this, and it is a strange one: the answer is not a flag on the section but a policy recorded in the symbol table, plus a checksum that lets the linker tell "the same function" from "the same signature with a different body".

A Simple Model

Two pieces of information, in two different tables, and both are needed:

  1. A bit in the section header — IMAGE_SCN_LNK_COMDAT, saying "this section is a deduplication candidate". Without it the linker will not even look.
  2. A policy in the symbol table's aux record — the Selection byte, saying how to choose between duplicates. Different policies answer different questions.
  3. A CheckSum in the same aux record — a hash of the section's contents, so the linker can tell an exact duplicate from a near miss.

The model's limit, and it is a real one: the checksum identifies the contents but says nothing about identity. Two functions can be byte-identical and be genuinely different, and two can share a signature and differ in one instruction. COFF answers only the first question, and leaves the second to the storage classes and to the linker complaining.

The Actual Detail

The source that produces the situation naturally:

struct S { int a, b; };
inline S make(int v) { S s; s.a = v; s.b = v * 2; return s; }
int f() { S s = make(3); return s.a + s.b; }
template<class T> T twice(T v) { return v + v; }
int g() { return twice(21); }

Compile it and count the sections:

$ clang++ --target=x86_64-pc-windows-msvc -c comdat.cpp -o comdat.obj
$ file comdat.obj
comdat.obj: x86-64 COFF object file, not stripped, 13 sections
$ llvm-objdump -h comdat.obj | head -18

Thirteen sections, and .text appears three times. So does .xdata, and so does .pdata. Duplicate section names are perfectly legal in COFF — they are not a bug and not even unusual — and they are how COMDAT works:

#NameCharacteristicsCOMDAT?
1.text0x60500020no
2.data0xC0300040no
3.bss0xC0300080no
4.xdata0x40300040no
5.text0x60501020yes
6.text0x60501020yes
7.debug$S0x42300040no
8.pdata0x40300040no
9.llvm_addrsig0x00100800no
10.xdata0x40301040yes
11.xdata0x40301040yes
12.pdata0x40301040yes
13.pdata0x40301040yes

The COMDAT sections are the ones whose characteristics differ from their non-COMDAT namesake by exactly the 0x1000 bit. Compare section 4 and section 10, both .xdata:

plain  .xdata:  40 00 30 40     0x40300040
COMDAT .xdata:  40 00 10 40     0x40301040
                              ^^^^^
                     0x1000 = IMAGE_SCN_LNK_COMDAT

The selection policies

Now the part that is not a flag. The Selection byte at offset 0x0e of the section's aux record tells the linker which duplicate to keep, and both values below occur naturally in this one object:

ValueNameRuleIn comdat.obj
0NONEnot a COMDAT; keep itevery ordinary section
1NODUPLICATESerror if a duplicate appearsnot used
2ANYkeep any one of themthe two .text COMDATs
3SAME_SIZEkeep one, but all must be the same sizenot used
4EXACT_MATCHkeep one only if byte-identicalnot used
5ASSOCIATIVEthis section lives or dies with the others in its groupthe .pdata and .xdata COMDATs

ANY is the one you will see most: the compiler has decided the bodies are equivalent, so the linker may keep whichever copy it likes. ASSOCIATIVE is subtler and more interesting, because the decision is not about this section at all — it is about a group.

Here is the pairing, straight from the aux records. The .pdata COMDAT (section 12) has Length 12, Selection 5, Number 5, and the .xdata COMDAT that shares its Number (section 10) has Length 8, Selection 5, Number 5. The Number field is the group identifier. Two sections with the same number and Selection = ASSOCIATIVE are a unit: the linker keeps them together or discards them together.

Why does that matter? Because an .xdata entry without its matching .pdata entry, or the reverse, is meaningless. A .pdata record says "this range of code unwinds using the unwind data at this address". Keep one half and the exception unwinder faults at runtime. Neither section is meaningful alone, so neither may be independently discarded — and ASSOCIATIVE is how the format says that.

And the CheckSum is what makes the decision safe. The verified values from this object:

SectionCheckSumSelection
5 .text0xEEE27D3DANY (2)
6 .text0x5127CE02ANY (2)
10 .xdata0x63791761ASSOCIATIVE (5)
11 .xdata0x1AB96B84ASSOCIATIVE (5)
12 .pdata0x7D3C6CACASSOCIATIVE (5)

Note that sections 5 and 6 have different checksums. They are both named .text, both marked COMDAT, and both use ANY — but they are not the same bytes. That is correct and expected: they are two different functions, each with its own body, and the compiler emitted each in its own COMDAT section. The checksum is not what makes them duplicates; the symbol names do. Two COMDAT sections with the same name and the same checksum coming from two different object files are the same function, and one is discarded.

A Real Example

The same source, two dialects, and the difference is entirely about COMDAT:

PropertyMSVC dialectGNU dialect
Sections98
.rdata count2, one COMDAT1
String literal name??_C@_03PLHFFLIH@ptr?$AA@none — inlined or not emitted
Machine0x86640x8664

The MSVC build merged the two string literals in sample.c into a deduplicated COMDAT .rdata, which is why it has a second .rdata and a mangled symbol pointing into it. The GNU build did not, so it has one fewer section. Both files are valid COFF; both use the same 20- and 40-byte records. A reader cannot tell which dialect it has from the container, only from the contents — which is why Machine plus the type tables is the minimum a reader needs.

And the i386 build, for the third data point:

Propertyx64 MSVCi386 MSVC
Machine0x86640x014C
Address size64-bit32-bit
add symboladd_add
Relocation namesIMAGE_REL_AMD64_REL32IMAGE_REL_I386_DIR32

That leading underscore is the cdecl symbol decoration, which x64 removed because its calling convention passes the first four arguments in registers rather than on the stack. It is the smallest possible demonstration that a symbol name in a COFF file is a linker name, not a source name — and that it changes with the target, not with the code.

Try It Yourself

$ clang++ --target=x86_64-pc-windows-msvc -c comdat.cpp -o comdat.obj
$ llvm-objdump -h comdat.obj
$ llvm-readobj --sections comdat.obj | grep -E 'Number:|Name:|LNK_COMDAT'
$ llvm-readobj --symbols comdat.obj | grep -B9 'Selection: [^0]' | head -40

What to look for, in order:

To make the deduplication actually happen, compile the same function into two object files and link them:

$ cat a.cpp
inline int shared(int v) { return v * 3; }
int use_a() { return shared(7); }
$ cat b.cpp
inline int shared(int v) { return v * 3; }
int use_b() { return shared(7); }
$ clang++ --target=x86_64-pc-windows-msvc -c a.cpp -o a.obj
$ clang++ --target=x86_64-pc-windows-msvc -c b.cpp -o b.obj
$ llvm-readobj --symbols a.obj | grep -c 'Selection: 2'
$ llvm-readobj --symbols b.obj | grep -c 'Selection: 2'

Both objects contain a COMDAT copy of shared. That is the situation the mechanism exists for, and you have just created it in two files.

Check Your Understanding

Without looking back: a COMDAT section has Selection = 5 (ASSOCIATIVE) and Number = 5, and another COMDAT section has Selection = 5 and Number = 5 as well. What will the linker do with the pair, and why would keeping one of them be a bug?

Apply It

Two object files each contain a COMDAT section named .text with Selection = ANY, and their CheckSum values are different. Your linker discards one of them anyway, and a function ends up calling the wrong body. What has the linker done wrong, and what is the correct reading of that checksum difference?

The general habit: a checksum that differs when a format says the items should match is not noise. It is the mechanism working — it is telling you that an assumption made earlier upstream, in the source, has been violated.

Connect

COMDAT is COFF's answer to a problem ELF solves differently, and the contrast is the point. ELF has no SHF_COMDAT; it has .group sections and SHT_GROUP, which group members explicitly and support associated metadata such as link-order hints. The ELF common sections concept covers that mechanism. COFF's approach is lighter — a bit, a policy byte, and a checksum — which is why it works well for C++ inline functions and why it is the only COMDAT-like mechanism most Windows toolchains expose.

The CheckSum field is the same instinct as DWARF's line-table checksums and as ELF's SHF_MERGE string sections: use a hash to decide whether two copies are the same, so you can merge or discard without a full comparison. The DWARF header concept makes the same argument for a different table.

That completes Module 2, and with it the format's two working halves: the container that says what the pieces are, and the tables that let a linker place them. What is deliberately not here is the other side of the same story.

Coming next, and specified in courses/coff/research.md but not yet written: the bigobj variant, whose header relocates its fields rather than widening them — a naive two-byte patch produces a file that every tool rejects — the .lib archive container, linker map files, and the short-export format. The PE course already covers the import and export directories of a linked image, so those are cross-linked rather than repeated.