Functions were fine. A function lives in code, code is mapped read-only and shared, and every process that loads the library gets the same address for it. Data does not work that way, and the reason is a single bit of history about who is allowed to write.
$ cat owner.c
extern int lib_data;
extern int lib_fn(int);
int main(void) {
printf("before lib_data=%d\n", lib_data);
lib_data = 999; /* the EXECUTABLE writes */
printf("after exe write lib_data=%d\n", lib_data);
printf("library lib_fn(0)=%d\n", lib_fn(0));
return 0;
}
There is one lib_data. It has an initial value of 7, set by the library’s initialiser code. The program writes to it. The library reads it. Three participants, one name, and nobody has said where the bytes live. That is the question this concept is about, and the answer is chosen by a compiler flag.
There are two defensible answers, and the linker must pick one per symbol.
Neither is universally better, and that is the difficulty. GLOB_DAT costs an indirection on every access. COPY is free at run time but spends program memory on a duplicate and creates a permanent coupling: the program can never load a library that disagrees about what lib_data is, because the library’s definition has been overridden by something that was linked in earlier.
The trade is speed against flexibility, and the linker is not in a position to know which you want. Which is why the decision is not the linker’s to make.
Same source, same library, and the only difference is how the program’s own translation unit was compiled:
$ clang -fPIE -pie -o own_pie owner.c -L. -lsym
$ clang -fno-pie -no-pie -o own_nopie owner.c -L. -lsym
$ for f in own_pie own_nopie; do
printf "%-10s %-5s " $f "$(readelf -hW $f | awk '/Type:/{print $2}')"
readelf -rW $f | grep lib_data | grep -oE 'R_X86_64_[A-Z_]+'
done
own_pie DYN R_X86_64_GLOB_DAT
own_nopie EXEC R_X86_64_COPY
Nothing in owner.c chooses between them. And the code generated differs to match:
$ objdump -d --no-show-raw-insn own_pie | grep -A1 'lib_data>' 1151: mov 0x2e78(%rip),%rbx # 3fd0 <lib_data> the ADDRESS 1158: mov (%rbx),%esi then the value $ objdump -d --no-show-raw-insn own_nopie | grep -A1 'lib_data>' 401141: mov 0x2ed9(%rip),%esi # 404020 <lib_data> the VALUE
Two instructions under GLOB_DAT, one under COPY. That is the cost of the indirection, and it is the whole reason the COPY path exists: in a non-relocatable executable the compiler knows the address cannot change, so it can encode the address directly in the instruction and skip the load entirely.
And here is the trap that has cost people an afternoon. Passing -no-pie at link time is not enough.
$ clang -no-pie -o wrong main.c -L. -lsym $ readelf -rW wrong | grep lib_data | grep -oE 'R_X86_64_[A-Z_]+' R_X86_64_GLOB_DAT <-- STILL GLOB_DAT
Because -no-pie only affects the link. The translation unit was compiled with the default -fPIE, so it emitted a GOT-indirect access, and the linker faithfully produced a GLOB_DAT for an access that genuinely goes through the GOT. You have to pass -fno-pie at compile time as well, and a build system that sets it in only one of the two places produces a non-PIE binary that still pays the PIE cost.
Now the behaviour, and the claim that has to be checked rather than assumed:
$ ./own_pie before lib_data=7 after exe write lib_data=999 library lib_fn(0)=999 $ ./own_nopie before lib_data=7 after exe write lib_data=999 library lib_fn(0)=999
The library sees 999 in both cases. Under COPY this is the whole point: the loader redirected the library’s references to the program’s copy, so there is one storage under two names. The intuitive reading — “the executable has a copy, so the library still has its own, and they can drift” — is not what happens.
Having been told the intuitive story, the useful thing is to try to break it, because this is where a course should report what it found rather than what it expected. Three participants: a library that defines the datum, two libraries that reference it, and the program.
$ cat libdef.c
int shared_data = 7; /* the DEFINITION */
$ cat libA.c
extern int shared_data;
int a_read(void) { return shared_data; }
$ cat libB.c
extern int shared_data;
int b_read(void) { return shared_data; }
$ cat main2.c
extern int shared_data; extern int a_read(void); extern int b_read(void);
int main(void) {
printf("initial shared=%d a=%d b=%d\n", shared_data, a_read(), b_read());
shared_data = 555; /* the executable writes */
printf("after shared=%d a=%d b=%d\n", shared_data, a_read(), b_read());
return 0;
}
$ ./two_pie initial shared=7 a=7 b=7 after shared=555 a=555 b=555 $ ./two_nopie initial shared=7 a=7 b=7 after shared=555 a=555 b=555
All six values agree, in both code models. I could not produce divergence on x86-64 with glibc 2.43, and this course is not going to claim a bug that does not reproduce on the machine the course was measured on.
What the divergence story is about, stated accurately: it is a real historical failure mode on targets where the COPY mechanism is implemented differently, and it is a real hazard for a case this test does not cover — a library loaded later, with dlopen, after the copy has been established. The COPY semantics are order-sensitive in a way GLOB_DAT is not, because a copy is a decision about which storage is canonical, and two different decisions taken at two different times cannot both be right. The reason this test does not diverge is that both libraries bind to the same canonical copy; the hazard appears when a participant is not in the initial link at all.
The practical rule that follows, and it is a good rule regardless of platform: export functions, not writable data. A function has no storage question because there is nothing to write. A writable exported datum forces one of the two mechanisms above, and both have costs — an indirection for every access, or a link-order coupling that constrains what your library can be used with. Every shared library that got this right exports accessor functions and keeps the variable static, and that is not a stylistic preference.
$ cd courses/sym/assets/samples $ ./build_samples.sh 2>&1 | sed -n '/Finding 9\/10/,/Finding 16/p' $ python3 crosscheck.py 2>&1 | sed -n '/\[7\]/,/\[9\]/p'
Then find the boundary of the claim, which is more interesting than confirming it:
1. Add a FOURTH participant: dlopen a library that
also references shared_data, AFTER main has written
555. Does it see 555 or 7? Build it -- this is the case
the static test cannot reach.
2. Make the definition `const int shared_data = 7;`.
Can the executable still get a COPY? What changes, and
why is that a good property?
3. Two .so files BOTH define `int shared_data`, neither
is a copy, and the program references it. Which one
wins? What is the rule, and is it the same rule as
for a weak definition?
4. Compile owner.c with -fPIE but link with -no-pie (the
"wrong" case above). Read the disassembly. Does the
program still work? What did it actually build?
Question 2 has the best answer. A const datum cannot be given a COPY, because a copy is a writable location by definition — the relocation would be patching a slot the program cannot write. The linker will use GLOB_DAT and the indirection is unavoidable, which is the correct outcome: the alternative would be a program that segfaults on its own initialiser. Making a library’s interface const is therefore not only good style, it changes the relocation the linker must emit.
This closes the runtime module, and it connects to Lazy, Eager, and the Flag That Does Nothing by exclusion. Data references have no lazy option, because the address is needed before any instruction can execute, so every data reference is resolved eagerly whether or not you pass -z now. That is why the COPY question has no timing escape hatch and why it is a design decision rather than a policy setting.
To The PLT and the GOT the connection is the two-instruction contrast. A function call is redirected through a stub because the call instruction has nowhere to put an unknown address. A data access is redirected through the GOT because the access instruction could, in principle, have held the address directly — and under COPY it does. The PLT exists because of a hardware limitation; the GOT exists because of a policy; COPY exists because the policy was declined.
Back to the static module, this is the concept where the two halves of the course collide. Two Definitions and One Tentative ended by observing that every mechanism in the static module is unavailable to a dynamic link, and gave the example of a .so that exports a writable datum. This is that example, resolved. The static answer was SHN_COMMON — defer the allocation to the link. The dynamic answer cannot defer, because the link that matters happens in another process at another time, and so the storage has to be decided now, by whichever of the two parties is more convenient.
And forward, into versioning, the connection is about the moment the decision gets revisited. Versioned Symbols exists because glibc needed to change what malloc does without breaking programs compiled against the old behaviour, and the mechanism it uses is to give one name several identities. That is the same problem as this one — a symbol whose meaning must change without relinking everything that uses it — solved on the name axis rather than the storage axis. The two concepts are the two answers a platform has to have: version the name when the behaviour changes, and avoid exporting storage when the ownership is ambiguous.