Back to course

Two Definitions and One Tentative

21 minintermediateModule 2: The Resolution AlgorithmContinues from sym-order
21 min · Module 2: The Resolution Algorithm · Intermediate

Why This Matters

Here is a header, and it is completely ordinary C:

/* config.h -- included by forty source files */
int   debug_enabled;
int   verbosity;

Forty translation units include it. Each one gets its own config.o. Each of those forty object files contains a definition of debug_enabled. And this linked, every day, for about thirty years, on every Linux distribution ever shipped.

Then a compiler changed its default, and this is the build error that appeared in thousands of projects at once:

$ make
cc -c a.c -o a.o
cc -c b.c -o b.o
cc -o prog a.o b.o
b.o:(.bss+0x0): multiple definition of `debug_enabled'
a.o:(.bss+0x0): first defined here
cc -o prog c.o a.o b.o
c.o:(.bss+0x0): multiple definition of `debug_enabled'
a.o:(.bss+0x0): first defined here
make: *** [prog] Error 1

Not one line of the project’s source changed. No header was edited. No dependency was mis-declared. The same forty object files that had linked yesterday did not link today, and the difference is a compiler default that flipped from -fcommon to -fno-common.

This is the most consequential ordinary fact in this course, and it is a fact about symbol binding rather than about linking technique. Understanding it requires seeing that int x; at file scope was never one thing. It was two, and only one of them survived.

A Simple Model

C has three ways to declare a file-scope object, and the difference between them is not obvious from the syntax:

  extern int x;      a DECLARATION. "x exists somewhere."
                               emits nothing. Undefined symbol.

  int x = 5;        a DEFINITION with an initialiser.
                               Real storage, real bytes.

  int x;            a TENTATIVE DEFINITION. This is the one
                               that is not what it looks like.

The third form is the entire subject of this concept. In C, a file-scope object declared without extern and without an initialiser is a tentative definition: the translation unit declares an intent to define it, but explicitly does not say where. C99 6.9.2p2 says each tentative definition “is a definition” — but then 6.9.2p5 says if there is more than one, the behaviour is undefined. The standard hands the decision to the implementation, and for thirty years the implementations all made the same one.

What they made it do was give the tentative definition a different storage class:

  -fcommon (the old default)      -fno-common (since GCC 10)

  SHN_COMMON, size 4               a real definition in
  an unallocated run in .bss       .bss, exactly like
  that the linker may satisfy       int x = 5; except
  from somewhere else               there are no bytes
                                    initialised

That is the whole trick, and it is not a weakening of the one-definition rule — it is a relocation of the definition. Under -fcommon, forty tentative definitions do not collide because they are not forty definitions. They are forty requests to share one, and the linker satisfies all forty from one allocation. Under -fno-common each tentative definition becomes a definition, and forty definitions of one name is precisely the error the rule exists to catch.

Does It Actually Work?

The flag is the only difference. Same three files, same compiler, two invocations:

$ cat tent_x.c
int cvar;
$ cat tent_y.c
int cvar;
$ cat tent_z.c
int cvar = 5; int main(void) { return cvar; }

$ clang -fcommon -c tent_x.c -o tent_x.o
$ readelf -sW tent_x.o | grep cvar
     2: 0000000000000000     4 OBJECT  GLOBAL DEFAULT  COM cvar
                              ^^^^^^^ SHN_COMMON

$ clang -o prog tent_x.o tent_y.o tent_z.o && ./prog ; echo $?
5                       <-- two tentative + one real: links, and 5 is correct

$ clang -fno-common -c tent_x.c -o tent_xn.o
$ readelf -sW tent_xn.o | grep cvar
     2: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 cvar
                                                 ^ section 3 = .bss, a REAL definition

$ clang -o prog tent_xn.o tent_yn.o tent_zn.o
tent_yn.o:(.bss+0x0): multiple definition of `cvar'
   tent_xn.o:(.bss+0x0): first defined here
tent_zn.o:(.data+0x0): multiple definition of `cvar'
   tent_xn.o:(.bss+0x0): first defined here

Read the section index column, because that single character is the entire mechanism. Under -fcommon the index is COM — a pseudo-section meaning “not in any section yet”, a request rather than a fact. Under -fno-common it is 3, which is .bss, which means the bytes exist and their address is decided.

Note also that the real definition in tent_z.c now conflicts too. cvar = 5 puts it in .data; the tentative ones are in .bss; and two different sections means two different storage. Before the change, the tentative definitions deferred to the real one and everyone got 5. After it, they do not defer to anything, because a tentative definition is no longer a thing that can be deferred.

And the merge, in the case where it still works, is by name and requires the strong definition to exist:

$ cat only_x.c
int lonely;            /* tentative, nothing else defines it */
$ clang -fcommon -c only_x.c -o only_x.o
$ readelf -sW only_x.o | grep lonely
     2: 0000000000000000     4 OBJECT  GLOBAL DEFAULT  COM lonely

Even with nothing to merge with, -fcommon leaves it in SHN_COMMON in the object file. The decision to allocate is deferred all the way to the link. That is the design: the object file does not claim to have decided anything.

A Real Example

Why a compiler was entitled to break this, and why the fallout was as large as it was. C99 6.9.2p5 says multiple tentative definitions are undefined behaviour, so a compiler that stops merging them is conforming and was always entitled to do it. The correctness argument is also straightforward: under -fno-common, a tentative definition is a real definition, and a real definition is initialised to zero, which is exactly what int x; was always promised to do. The old behaviour was arguably the buggy one, because it meant the value of x could depend on whether some other translation unit happened to assign it.

The compatibility argument is what nobody had an answer for. The break did not happen in a source file; it happened in a prebuilt binary. Someone’s libfoo.a, compiled in 2004 with tentative definitions, was linked in 2024 against a 2024 object that also has a tentative definition of the same name. One of them is now a definition and the other is now a definition, and they collide. No source change, no recompile of the library, no way for the application to fix it.

That is the general shape, and it recurs. A format that lets each producer record a local decision needs a way to make two different local decisions interoperate, or the decision has to be made by the language instead. C made it the compiler’s decision and then changed its mind. C++ never had the problem, because inline variables have real semantics from the start. Rust has no tentative definitions at all. And the object-files course measured a third answer in a different format: COFF has no SHN_COMMON and instead solves the same problem with .bss plus a COMDAT group, which is why the same C source that broke every ELF project in 2020 does not break COFF projects.

The practical advice, and it is the advice that is actually correct: do not put tentative definitions in a header. Write extern int x; in the header and exactly one int x = 0; in one .c file. That is correct under every flag, has been since 1989, and makes the number of definitions in your build a property you chose rather than a property of how many files included a header.

Try It Yourself

$ cd courses/sym/assets/samples
$ ./build_samples.sh 2>&1 | sed -n '/Finding 5/,/Finding 6/p'
$ python3 crosscheck.py 2>&1 | sed -n '/\[3\]/,/^$/p'

Then separate the three cases, which is the whole skill:

  1. a.c:  extern int v;  int get(void){return v;}
     b.c:  int v = 9;
     Link under BOTH flags. Does it ever fail? Why not?

  2. a.c:  int v;   int get(void){return v;}
     b.c:  int v = 9;
     Link under BOTH flags. What changes, and what does the
     program PRINT in each case?

  3. a.c:  static int v;  int get(void){return v;}
     b.c:  static int v;  int get2(void){return v;}
     Link. Two symbols named v, or one? How would you tell
     from the binary?

  4. Question 2 is the famous one. Under -fcommon, what does
     the program print, and WHY is that arguably a bug in
     the old behaviour rather than a feature?

Question 4 is the one to spend time on, and the answer is the most interesting thing in this concept. Under -fcommon, int v; in a.c does not get you zero — it gets you whatever b.c put there, and the value of a variable depended on link order. The C standard never promised that; it promised the tentative definition was a definition, and a definition with no initialiser is zero-initialised. The old behaviour was the one that departed from the standard, and -fno-common is the fix.

Connect

This closes the algorithm module, and the closing argument is about decisions made in the wrong place. The Algorithm, Measured and Order, Archives and the Map File described how a decision is reached; this one is a case where the decision should never have been left to the reader of the object file at all, and thirty years of toolchains deferred it to the last possible moment.

Into the identity module, the connection is exact. Binding, and the Two Tables presented the three bindings as a hierarchy and said WEAK is a permission rather than a priority. SHN_COMMON is a second permission, in a different field, answering the same question Q2 from A Name Is Not a Symbol. Two mechanisms for “this definition is optional” in the same 24-byte structure — a binding nibble and a section index — is the clearest evidence that ELF does not separate the two questions cleanly.

Back to the object files course, this is where two of its findings meet. COMMON and the ABI Break measured this exact phenomenon and located it in the ELF format; this concept explains the mechanism and shows that the break was a compiler default rather than a format change, which is the more precise and more useful account. COMDAT is the other answer to the same problem in the same language — C++ reached it through inline and solved the identical issue with a different structure, and the object-files course found that C has no mechanism for it at all.

And the connection forward is the one that makes the module hang together. Every mechanism in this module is a static-link mechanism, and every one of them is unavailable to a dynamic link — because a shared object cannot be edited after the fact, so a tentative definition in a .so has to become a real one, and two .so files that both have SHN_COMMON for the same name are two unrelated variables with the same spelling. That is the same class of failure as the copy-relocation question in Who Owns the Storage, and it is why a library that exports a writable datum is a substantially harder problem than one that exports functions.