When you build a shared library (.so), you decide which functions are part of your public API and which are internal implementation details. Symbol visibility is the ELF mechanism for this — it controls what the dynamic linker can see and reference from outside the library.
Correct visibility prevents symbol collisions between libraries, reduces dynamic linker overhead, and enables better optimization.
Think of visibility as four levels of "doors" on your library:
Visibility is stored in the low 2 bits of st_other in the Elf64_Sym structure:
| Visibility | Constant | Value | Behavior |
|---|---|---|---|
| Default | STV_DEFAULT | 0 | Symbol is exported. Dynamic linker can interpose it (LD_PRELOAD). |
| Internal | STV_INTERNAL | 1 | Symbol is local to the module. Not visible outside. May have special meaning for processor. |
| Hidden | STV_HIDDEN | 2 | Symbol is not exported. Dynamic linker cannot resolve it from other modules. |
| Protected | STV_PROTECTED | 3 | Symbol is exported but references within the module resolve to the local definition (no interposition). |
How to set visibility in C/C++:
| Visibility | C/C++ syntax | Effect |
|---|---|---|
| Default | void public_api() -- no attribute needed | Exported, interposable |
| Hidden | __attribute__((visibility("hidden"))) void internal_fn() | Not exported |
| Protected | __attribute__((visibility("protected"))) void stable_api() | Exported, not interposable |
Compile with -fvisibility=hidden to make all symbols hidden by default.
A shared library with controlled visibility:
# libdemo.c defines 4 functions: # exported() -- default visibility (EXPORTED) # hidden_fn() -- hidden visibility (NOT exported) # protected_fn() -- protected visibility (exported, no interposition) # default_fn() -- default visibility, but -fvisibility=hidden makes it HIDDEN $ gcc -fvisibility=hidden -shared -o libdemo.so libdemo.c $ readelf -sW libdemo.so | grep -E 'DEFAULT|HIDDEN|PROTECTED' 1: 0000000000001129 5 FUNC GLOBAL DEFAULT 13 exported 2: 0000000000001132 5 FUNC GLOBAL HIDDEN 13 hidden_fn 3: 000000000000113b 5 FUNC GLOBAL PROTECTED 13 protected_fn 4: 0000000000001144 5 FUNC GLOBAL HIDDEN 13 default_fn
Note: default_fn is hidden because we compiled with -fvisibility=hidden. Only exported is truly visible to other libraries.
Checking what a library actually exports:
$ nm -D --defined-only libdemo.so 0000000000001129 T exported 000000000000113b T protected_fn # hidden_fn and default_fn don't appear — they're not in .dynsym
# Create a library with mixed visibility # vis.c has pub_fn (default) and priv_fn (hidden) # Build with hidden default gcc -fvisibility=hidden -shared -o libvis.so vis.c # See what's exported nm -D --defined-only libvis.so # Only pub_fn appears # See everything nm --defined-only libvis.so # Both functions appear
What does HIDDEN visibility mean for a symbol?
Why would you use PROTECTED visibility instead of DEFAULT?
Now you understand how ELF symbols are named (symbol table), scoped (binding), and exported (visibility). The next module covers relocations — how the linker patches addresses when combining object files.