How Linkers Resolve Weak Symbols in C++

When compiling C++ programs, identical symbol definitions often appear across multiple translation units due to inline functions, template instantiations, and explicit attributes. This article explains how the linker distinguishes between strong and weak symbols, the exact resolution rules it applies when encountering duplicate weak definitions during binary merging, and the underlying mechanism linkers use to deduplicate code while maintaining the C++ One Definition Rule (ODR).

Strong Symbols vs. Weak Symbols

In object files (such as ELF or Mach-O formats), symbols are classified as either strong or weak:

  • Strong symbols: By default, global functions, non-inline functions, and initialized non-static global variables are strong.
  • Weak symbols: Functions marked inline, template instantiations, non-initialized global variables (common symbols in C), and symbols explicitly decorated with compiler attributes (like __attribute__((weak))) are marked as weak.

The Linker Resolution Rules

When the linker combines multiple object files into an executable or shared library, it resolves symbol name collisions using three deterministic rules:

  1. Multiple Strong Symbols Cause an Error: If two or more object files define a strong symbol with the identical mangled name, the linker halts with a "multiple definition" error.
  2. Strong Beats Weak: If a symbol exists as a strong definition in one object file and as a weak definition in one or more other object files, the linker chooses the strong symbol. All weak definitions are quietly discarded, and all references are rewritten to point to the strong definition.
  3. Multiple Weak Symbols Resolve to One Definition: If multiple weak definitions exist and no strong definition is present, the linker arbitrarily chooses one of the weak definitions and discards the rest. All references throughout the binary are patched to target the chosen instance.

COMDAT Groups and Deduplication

Modern toolchains implement weak symbol resolution primarily through COMDAT groups (or sections like .gnu.linkonce in older formats).

When the compiler generates code for an inline function or template specialization:

  1. It places the compiled machine code and associated metadata (such as exception handling tables and debug info) into a distinct COMDAT section named after the symbol.
  2. During the link phase, the linker scans all incoming object files for duplicate COMDAT group identifiers.
  3. It retains the first COMDAT group it encounters and discards all subsequent duplicate groups.
  4. References to the symbol in other translation units are relocated to point to the surviving definition.

Practical Implications in C++

  • Pointer Consistency: The C++ standard mandates that a function has a single, unique memory address across the entire program. Linker-level deduplication ensures that function pointers to an inline function evaluate to the same memory address across different translation units.
  • ODR Violations: The linker assumes all weak definitions sharing the same mangled name are functionally identical. If two definitions differ—such as through different preprocessor flags or differing macro values in headers—the linker still picks one arbitrarily. This creates undefined behavior, leading to silent bugs that do not produce compile-time or link-time errors.