Four attributes, one per visibility. Compile them and look at the result:
$ cat vis.c
int def_fn(int x) { return x + 1; }
__attribute__((visibility("hidden"))) int hid_fn(int x) { return x + 2; }
__attribute__((visibility("protected"))) int prot_fn(int x) { return x + 3; }
__attribute__((visibility("internal"))) int int_fn(int x) { return x + 4; }
int def_data = 1;
__attribute__((visibility("hidden"))) int hid_data = 2;
$ clang -O1 -fPIC -shared -o libvis.so vis.c
$ llvm-nm libvis.so | grep -E 'def_fn|hid_fn|prot_fn|int_fn|def_data|hid_data'
0000000000004008 D def_data
000000000000400c d hid_data
0000000000001100 T def_fn
0000000000001110 t hid_fn
0000000000001130 t int_fn
0000000000001120 T prot_fn
Read the case of those letters. Uppercase means GLOBAL, lowercase means LOCAL. And hid_fn and int_fn are lowercase — they are LOCAL symbols.
So visibility("hidden") and visibility("internal") did not set a field. They demoted the binding from GLOBAL to LOCAL. The information is not in the two low bits of st_other where a reader expects to find visibility; it is in the binding nibble of st_info, alongside the answer to a completely different question.
That is a genuinely surprising result and it is the reason this concept exists. It is also the cleanest available demonstration that binding and visibility are not two strengths of the same idea, because the mechanism that implements two of the four visibility values is the mechanism that implements a different concept entirely.
Visibility answers: if another shared object already defines this name, does it win over me? That is a question about conflict, and only DEFAULT permits a conflict.
Two columns deserve emphasis because they are the counter-intuitive ones.
Column two.HIDDEN and INTERNAL are not preemptible, but not because of any rule — because there is nothing there to preempt. The name is absent from .dynsym, so no loader can bind to it. A symbol that is not in the table cannot be interposed. If you were checking preemption safety by looking for “not preemptible” you would mark these two as safe for a reason that has nothing to do with the visibility attribute you wrote.
Column three. This is the one that trips people. If you dump st_other expecting to find STV_HIDDEN, you will not find it, and if you dump st_info expecting to find it you will find a LOCAL binding and probably not connect the two. The compiler has resolved the visibility into a binding before emitting the table. The attribute is a source-level request; the table records the decision that satisfied it.
Confirm the .dynsym column directly, because that is the column the table above claims:
$ readelf --dyn-syms -W libvis.so | grep -cE ' (hid_fn|int_fn|hid_data)$'
0
$ readelf --dyn-syms -W libvis.so | grep -E ' (def_fn|prot_fn|def_data)$'
5: ... FUNC GLOBAL DEFAULT 12 def_fn
8: ... FUNC GLOBAL DEFAULT 12 prot_fn
9: ... OBJECT GLOBAL DEFAULT 19 def_data
Three exported, three not, and the split is exactly the one predicted. Now the measurement that makes the distinction between HIDDEN and PROTECTED real rather than definitional — both are un-preemptible, but only one of them is findable:
$ cat u.c
#include <stdio.h>
extern int def_fn(int);
extern int prot_fn(int);
int main(void) { return def_fn(1) + prot_fn(1); }
$ clang -o prog u.c -L. -lvis -Wl,-rpath,'$ORIGIN'
$ readelf -rW prog | grep -E 'prot_fn|def_fn'
...JUMP_SLOT prot_fn + 0
...JUMP_SLOT def_fn + 0
Both are bound the same way at link time, through a PLT slot. The difference only becomes visible when a second shared object is loaded and offers its own definition. For def_fn the loader is permitted to bind the program's reference to that other definition; for prot_fn it is not, because the reference was resolved inside the library that owns it.
That is the whole practical content of PROTECTED: it is the only value that lets a name stay exported while being immune to interposition. For a library whose internal calls must not be diverted by an unrelated LD_PRELOAD, that is exactly the property you want, and it is the reason the fourth value exists rather than the third being enough.
Here is a real interposition, measured, so that “preemptible” stops being abstract. Build two libraries that both export describe:
$ cat a_desc.c
const char *describe(void) { return "from A"; }
$ clang -fPIC -shared -o liba.so a_desc.c
$ cat b_desc.c
const char *describe(void) { return "from B"; }
$ clang -fPIC -shared -o libb.so b_desc.c
$ cat uses.c
#include <stdio.h>
extern const char *describe(void);
int main(void) { printf("%s\n", describe()); }
$ clang -o prog uses.c -L. -la -Wl,-rpath,'$ORIGIN'
$ ./prog
from A
$ LD_PRELOAD=./libb.so ./prog
from B <-- the DEFAULT-visibility call was diverted
Now mark describe in liba.so protected and repeat:
$ cat a_desc.c
__attribute__((visibility("protected")))
const char *describe(void) { return "from A"; }
$ clang -fPIC -shared -o liba.so a_desc.c
$ LD_PRELOAD=./libb.so ./prog
from A <-- protected: NOT diverted
Same program, same preload, opposite result, and the only difference is one attribute on a function in a library the program never mentions. This is also the security shape: a LD_PRELOAD can divert any DEFAULT-visibility call, which is why a setuid program ignores the variable, and why hardened builds compile with -fvisibility=hidden as a blanket measure.
And here is where that blanket measure collides with another feature, which is worth seeing because it is a real interaction rather than a hypothetical one. Build the same library with a version script:
$ cat ver/vmap
V1 { global: describe; local: *; };
$ clang -fPIC -shared -fvisibility=hidden \
-Wl,--version-script=ver/vmap -o libh.so a_desc.c
$ llvm-nm -D --defined-only libh.so
0000000000000000 A V1@@V1 <-- the version node, and NOTHING else
The script says global: describe and describe is still not exported. A version script can only narrow visibility; it cannot confer it. -fvisibility=hidden has already demoted the binding to LOCAL, and a LOCAL symbol cannot be re-exported by a filter that operates on the exported set. Versioned Symbols takes this apart in full, because it is the single most surprising interaction in the course.
Section [1] of the sample build is this concept’s evidence:
$ cd courses/sym/assets/samples $ ./build_samples.sh 2>&1 | sed -n '/Finding 1\/2/,/Finding 3/p' $ python3 crosscheck.py 2>&1 | sed -n '/\[1\]/,/^$/p'
Then extend it. The interesting question is not the four values but which mechanism each uses:
Question 3 is the one to think hardest about, and it has no answer in this course. Two libraries exporting the same PROTECTED name link silently, and each one's internal calls go to its own copy. Whether that is correct depends on whether the two were meant to be substitutable for one another, and the format cannot tell you. Compare GLOBAL, where the same situation is a link error for a dynamic symbol only if you ask for it — and the asymmetry is the reason PROTECTED is a tool for libraries rather than a default for programs.
This closes the identity module, and the three concepts together make one argument: A Name Is Not a Symbol supplied the two questions, Binding, and the Two Tables answered the first with three bindings and two tables, and this one answered the second with four values and two mechanisms. The shape to carry forward is that each format records the decision, not the request — a static request becomes a LOCAL binding, a hidden request becomes a LOCAL binding, and in both cases the source-level vocabulary is not recoverable from the table.
Into the algorithm module: binding and visibility together are what make The Algorithm, Measured a decision procedure rather than a search. A LOCAL name is not a candidate for anything outside its file, a WEAK definition is a candidate that may lose, and a DEFAULT definition is one that may lose to something loaded later. Two Definitions and One Tentative is where those rules produce the two outcomes you saw at link time and the third one you did not.
Into the runtime module, the connection is direct and is the reason the interposition experiment above matters. Everything in that module is the loader asking the same questions against .dynsym alone — which is to say, against the exported subset that this concept just measured. That is why hid_fn being absent from .dynsym is a stronger statement than “not exported”: it is not merely unfindable by another library, it is unfindable by the loader, so the compiler’s demotion to LOCAL is what makes the guarantee possible in the first place.Two Hash Tables, One Job then shows that the loader’s entire index over that table is built on the names that survived this concept’s filtering.
One outside connection, because it is the design lesson rather than the mechanism. A format that lets a producer record a decision locally — static, hidden, COMMON — needs those decisions to be mutually compatible, and nothing in the format guarantees it. Two Definitions and One Tentative is the case where that guarantee was missing for thirty years and cost every prebuilt library on the platform when it was withdrawn. Visibility has the same shape and got lucky: PROTECTED and DEFAULT coexisted for years precisely because the safe choice happened to also be the narrow one.