Module 3 decoded the COMDAT mechanism: a section flag, a selection kind, a checksum in an auxiliary symbol record. That is the vocabulary. What it did not do is show it deciding anything, because a mechanism described in a header is a mechanism you have only read about.
So here is the smallest possible instance of the problem COMDAT exists to solve, and the whole thing watched end to end. Two C++ files, each with the same inline function. Each compiler emits its own copy into its own object. The two copies are byte-for-byte identical. If both were linked in, the binary would contain the same function twice under the same name, and every call would go to one of them.
This is not a contrived situation. It is what happens in any C++ program where two translation units both include a header defining an inline function, which is most of them. The linker resolves it silently, correctly, and without leaving a trace in the output file — which is exactly why it is worth deliberately creating the situation and watching it happen.
A COMDAT group is a section plus a rule for what "the same group" means. Three ingredients, all in the object:
IMAGE_SCN_LNK_COMDAT — the linker's permission to discard this section if an equivalent one already exists.IMAGE_COMDAT_SELECTION_ANY means "identical contents, name irrelevant"; EXACT_MATCH means "identical contents and the same name"; SAME_SIZE means "same size, contents ignored"; NONE means the section is not a COMDAT at all.And the algorithm is short:
Two features of that sketch are the whole design. The linker keeps the first and discards the rest, so the winner depends on link order — which is why the choice must be content-based and not position-based, or a program would behave differently depending on the order a build system happened to pass its files. And discarding is safe only because the survivor is byte-identical, so every relocation that pointed into the discarded copy can be redirected to the kept one. The discarded section is not thrown away, it is merged.
SELECTION_ANY the checksum is the only evidence that two sections are equivalent. Get it wrong in the direction of accepting unequal sections and the linker silently produces a binary where some call sites use one function body and others use a different one — same name, same signature, different behaviour. That is a genuinely horrible bug class, and it is why this field exists rather than the linker simply comparing bytes. The failure is silent, which is the worst property a linker bug can have.Two files. Both define the inline function shared, which returns its argument multiplied by three; each then calls it with a different constant.
| File | Defines | Calls it with |
|---|---|---|
comdat_a.cpp | inline int shared(int v) — the same one-line body returning v * 3 | use_a with 7 |
comdat_b.cpp | the identical function, character for character | use_b with 11 |
The bodies are on one line because they are single expressions, and the point of the exercise is that they are identical — so any difference in the compiled output has to come from the compiler or the linker, not from the source.
$ clang++ --target=i686-pc-windows-msvc -c comdat_a.cpp -o comdat_a.obj
$ clang++ --target=i686-pc-windows-msvc -c comdat_b.cpp -o comdat_b.objRead the section tables with the parser shipped with this course. Each object has two.text sections, and they are not interchangeable:
comdat_a.obj
sec1 .text rawsize=21 char=0x60500020
IMAGE_SCN_CNT_CODE | IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_READ
sec4 .text rawsize=12 char=0x60501020
IMAGE_SCN_CNT_CODE | IMAGE_SCN_LNK_COMDAT | MEM_EXECUTE | MEM_READ
comdat_b.obj
sec1 .text rawsize=21 char=0x60500020 (identical)
sec4 .text rawsize=12 char=0x60501020 (identical)
Two details in those characteristic values. The only difference between the two .text sections in the same object is the single bit 0x1000 — 0x60500020 versus 0x60501020. Everything else, including the alignment encoded in the high bits, is the same. And the ordinary .text holds use_a or use_b, which are genuinely different functions and must both be kept; the COMDAT .text holds shared, which is not.
The twelve raw bytes of section 4, from both objects:
comdat_a.obj sec4: 55 89 e5 8b 45 08 6b 45 08 03 5d c3 comdat_b.obj sec4: 55 89 e5 8b 45 08 6b 45 08 03 5d c3
Byte for byte, the same twelve. And that is exactly what shared compiles to, if you want to read it as x86:
0: 55 push %ebp 1: 89 e5 mov %esp, %ebp 3: 8b 45 08 mov 0x8(%ebp), %eax ; eax = v 6: 6b 45 08 03 imul $0x3, 0x8(%ebp), %eax ; eax = v * 3 c: 5d pop %ebp d: c3 ret
Twelve bytes, no relocations, no references to anything outside the function. The smallest possible COMDAT, and the easiest possible case.
Now the auxiliary symbol records. Each object has a section symbol for its COMDAT section, and each one's auxiliary record carries the rule and the hash:
comdat_a.obj .text (sec4) Number: 4 Selection: Any (0x2) Checksum: 0xF787F24A comdat_b.obj .text (sec4) Number: 4 Selection: Any (0x2) Checksum: 0xF787F24A
The two checksums are identical, and the reason is now visible rather than assumed: the contents are identical, the selection is ANY, and ANY means the checksum is over the content alone. A compiler could not have produced two different hashes here without a bug, and two compilers producing the same hash on different different contents would be a hash collision — which is why the checksum algorithm has to be good, because the linker's correctness depends on it.
Also note Selection: Any rather than Exact Match. ANY ignores the section's name, which matters here: the COMDAT group is named by the symbol?shared@@YAHH@Z, not by the section, and the section is just called .text like the non-COMDAT one. If the rule had been EXACT_MATCH the two sections would still qualify — they have the same name — but ANY is what lets a compiler put every COMDAT function into a plain .text section instead of inventing a name per function.
$ ld --oformat pei-i386 -m i386pe comdat_a.obj comdat_b.obj \
-o comdat_linked.exe --entry use_a -Map comdat_linked.map
$ grep -A6 'Discarded input' comdat_linked.map
.debug$S 0x00000000 0x5c comdat_a.obj
.llvm_addrsig 0x00000000 0x1 comdat_a.obj
.text 0x00000000 0xc comdat_b.obj <-- the duplicate
.debug$S 0x00000000 0x5c comdat_b.obj
.llvm_addrsig 0x00000000 0x1 comdat_b.obj
There it is. The linker discarded comdat_b.obj's twelve-byte .text and kept comdat_a.obj's. Note the size column: 0xc = 12, matching the raw size of the COMDAT section and not the 21-byte ordinary .text. The discarded thing is precisely the duplicate, not a whole file's worth of code.
And the placement rows from the same map, which is the other half of the proof — the surviving copy, and where the callers went:
.text 0x00401000 0x15 comdat_a.obj
0x00401000 ?use_a@@YAHXZ
*fill* 0x00401015 0xb
.text 0x00401020 0xc comdat_a.obj
0x00401020 ?shared@@YAHH@Z
*fill* 0x0040102c 0x4
.text 0x00401030 0x15 comdat_b.obj
0x00401030 ?use_b@@YAHXZ
?use_a from comdat_a.obj, then the survivor ?shared — credited to comdat_a.obj — then ?use_b from comdat_b.obj. comdat_b.obj contributed one function, not two. And the disassembly confirms the count independently of the map's claims:
$ llvm-objdump -d --section=.text comdat_linked.exe
00401020 <?shared@@YAHH@Z>:
00401030 <?use_b@@YAHXZ>:One?shared. Three independent sources — the discard list, the placement rows, and the disassembly — all agreeing, and none of them derived from the others.
Here is the part that makes discarding safe rather than merely small. comdat_b.obj's use_b called shared — and in its own object, that call was a REL32 fixup against comdat_b.obj's own COMDAT section, whose address was zero. The linker discarded that section, so it had to redirect the call to the survivor in comdat_a.obj, and recompute the displacement for the new distance. The fixup in comdat_b.obj was written expecting a different address, and the linker had to correct it after making the decision to discard.
That is the reason the algorithm is stated as "discard, then point the symbols at the accepted one" rather than just "discard". A discarded COMDAT section is not a deletion; it is an alias. The references to it become references to a section in another file, which means the relocation step and the deduplication step have to be coordinated — and the order matters, because a relocation computed against a discarded section's address is wrong the moment the section goes away.
Change one file so the inline functions are genuinely not the same, and the lesson stops being theoretical:
$ sed -i 's/v \* 3/v * 4/' comdat_b.cpp
$ diff comdat_a.cpp comdat_b.cppThat is the entire change: the 3 becomes a 4 in the multiplier, in one file, leaving everything else identical.
The compiler responds correctly. v * 4 is not imul by an immediate at all — the compiler rewrote it as mov then shl $2 — so the section grew from twelve bytes to fourteen, and the contents are now plainly different:
a.obj sec4: 55 89 e5 8b 45 08 6b 45 08 03 5d c3 (v * 3, 12 bytes) b.obj sec4: 55 89 e5 8b 45 08 8b 45 08 c1 e0 02 5d c3 (v * 4, 14 bytes)
And the checksums, correctly, differ:
a.obj Checksum: 0xF787F24A
b.obj Checksum: 0x3B2D87C1Everything so far says the linker must refuse to merge these. It does not. Here is the discard list from that link:
Discarded input sections
.text 0x00000000 0xe b.obj <-- discarded anywayb.obj's fourteen-byte copy was discarded despite the different checksum, and the placement rows show only a.obj's version surviving. Now the consequence, from the disassembly of the resulting binary:
00401020 <?shared@@YAHH@Z>: 401026: 6b 45 08 03 imull $0x3, 0x8(%ebp), %eax <-- a.obj's v * 3 00401030 <?use_b@@YAHXZ>: 401034: c7 04 24 0b 00 00 00 movl $0xb, (%esp) ; push 11 40103b: e8 e0 ff ff ff calll 0x401020 <?shared@@YAHH@Z>
use_b loads 11 and calls ?shared — and the only ?shared in the binary multiplies by three. So use_b() returns 33, in a program whose source says 44.
ld in PE emulation mode, deduplicated the COMDAT group on the symbol name alone, without consulting the checksum in the auxiliary record. Two different function bodies shared a name, one was discarded, and the survivor was bound to both call sites. The build produced no diagnostic: the link succeeded, the image is well-formed, every tool reads it cleanly, and the result is wrong.Now the shape of the bug, because this is the most valuable thing in the concept. No error, no warning, no malformed output. The binary is a valid program that computes the wrong answer. And the reason no test would catch it is not that tests are bad — it is that the failure is invisible to every layer except the one that made the mistake:
Every layer reported success. The bug lived entirely in the gap between "the information is present" and "the information is checked", and no amount of testing the layers individually would have found it. That is the shape of the worst class of bug there is, and it is why the discipline in this course is to verify each claim against a file rather than against a document. The document said the checksum was there. The map file said the section was discarded. Only putting the two together revealed that the second should never have happened — and the checksum, which is the whole point of the mechanism, was the evidence that made the mistake visible at all.
The counterfactual is worth stating, because it is what a correct consumer does. The two sections differ in both size (12 against 14) and checksum. A linker honouring the checksum must decline the merge, and then ?shared@@YAHH@Z is defined twice — which is a genuine error, because C++ does not permit two different definitions of an inline function. The correct outcome is a build failure. The one observed was a build success and a wrong answer. Between those, the loud one is infinitely preferable, and the checksum field exists to make the loud one reachable.
So the earlier reading of the algorithm was optimistic in exactly one place, and it is worth seeing where. The sketch said the linker keeps the first group whose "checksums match and the selection's extra condition holds". Every part of that sentence is in the object file. A linker that skips the checksum is not making a different decision from the specification; it is making no decision about equivalence, and falling back on name identity — which happens to be right far more often than anyone would like.
One more consequence worth seeing, because it explains why COMDAT shows up where it does. Change the selection to SAME_SIZE and the two twelve-byte functions still deduplicate — same size, contents ignored. That is a faster rule and a weaker one, and it is the right rule for data where a producer can cheaply guarantee equal size but not equal content. So:
| Selection | Requires | Safe when |
|---|---|---|
ANY | Matching checksums | Always. The contents are provably the same |
EXACT_MATCH | Matching checksums and the same name | Always, and stricter — refuses merges you might have wanted |
SAME_SIZE | Equal sizes only | Only when the producer can guarantee size equality implies content equality |
ASSOCIATIVE | Nothing beyond being a COMDAT | Never for arbitrary content. Used for genuinely commutative data |
NONE | — | The section is not a COMDAT and will never be discarded |
Read the last column as the specification of what a compiler must guarantee before choosing a selection. The selection is a promise, and the linker is entitled to act on it. A producer that picks SAME_SIZE for two functions that happen to be the same length but are not the same code has not saved anything — it has moved the bug from link time to run time, and removed the diagnostic that would have caught it.
$ clang++ --target=i686-pc-windows-msvc -c comdat_a.cpp -o comdat_a.obj
$ clang++ --target=i686-pc-windows-msvc -c comdat_b.cpp -o comdat_b.obj
$ llvm-readobj --symbols comdat_a.obj | grep -A2 'Selection'
$ ld --oformat pei-i386 -m i386pe comdat_a.obj comdat_b.obj \
-o out.exe --entry use_a -Map out.map
$ grep -A6 'Discarded input' out.map3 to a 4 in comdat_b.cpp, relink, and disassemble. Confirm that use_b now calls the surviving * 3 body and that the link produced no warning. Then read the two checksums and confirm they differ. You have just built a binary that computes a different answer from its source, and the whole experiment is four commands — that ratio is the argument for verifying link-time behaviour rather than trusting it.comdat_b.obj first on the command line. Now b's copy survives. The linked binary is byte-identical — because the contents are identical — but the map credits the section to a different file. Confirm both halves of that claim. It is the cleanest available demonstration of why the rule has to be content-based rather than order-based.inline function in a header, include it from three files, and relink. Watch the discarded sizes scale to a third of the total. Then flip the alignment to 1 byte and watch the *fill* rows change while the dedup stays identical — two independent linker decisions, cleanly separable.inline keyword from both files and relink. You now have two genuinely distinct shared functions with the same name, and the linker will refuse. That refusal is the feature: COMDAT is the mechanism that makes "one definition, many copies" expressible, and its absence would make inline functions unlinkable across translation units.Without looking back: two objects each contain a twelve-byte COMDAT .text with Selection: Any and Checksum: 0xF787F24A. The twelve bytes are identical. The linker discards one of them. Now the caller in the discarded object still needs to reach that function. What has to happen, and what would go wrong if the linker simply deleted the section and left the call site alone?
You maintain a large C++ project. It links, it passes its tests, and it has done so for years with the same toolchain. A colleague rebuilds on a different machine, with a different linker build, and one function — is_valid(), in a header included by forty translation units — starts returning inconsistent results. Nothing in the source changed. The old machine still works. Given what you have just seen, what is your leading hypothesis, how would you test it cheaply, and what would the result tell you?
The general habit: a hash stands in for a comparison so the comparison can be skipped. That is a fine trade until the hash's input differs from what you think it is. Whenever a system uses a cheap check as a proxy for an expensive one, find out exactly what went into the cheap check — and if the consequence of being wrong is silent, verify the proxy once by hand.
This is the payoff for the symbol table and its auxiliary records. You decoded a Selection field and a Checksum field as numbers in a byte layout. They are a contract, between a compiler that promises something and a linker that spends it. A format that carries a decision in a header field you only read as data is a format whose section characteristics and symbol table are not a description of the file but a set of instructions to whoever consumes it.
The mechanism is the COFF instance of something every executable format needs. ELF has .gnu.linkonce.* section groups with a group signature, and the same deduplication semantics. Mach-O has linker_optimized and dead_strip sub-sections. The names differ and the storage differs and the hash algorithm differs, but the problem is identical, and it exists in all three for the same reason: independently compiled translation units cannot coordinate, and something has to arbitrate after the fact. Learning one instance properly is how you recognise the other two.
That closes the COFF course. A COFF object was decoded field by field in Module 1, its symbol table and string table in Module 2, its container formats and the COMDAT contract in Module 3 — and now a real link was run, its five steps observed, its transcript read, and its deduplication watched working. The object side is finished.
Back to the course index.