Why Does Symbol Visibility Matter?
When you build a shared library in C, by default every global function and variable is exported — it becomes visible to any program that links against the library. This might seem harmless, but it causes three real problems that library designers must manage carefully:
- ABI stability problem: Users start depending on your internal functions. Now you cannot change or remove them without breaking those users.
- Symbol interposition: A global symbol in your library can accidentally “shadow” or be shadowed by same-named symbols in other libraries, causing subtle bugs at runtime.
- Performance: Every exported symbol adds an entry to the dynamic symbol table that must be loaded and processed at runtime, even if it is never called externally.
The solution is to only export the symbols that form your documented public API. Everything else should be hidden. This section covers three techniques to achieve this.
The Three Problems of Uncontrolled Visibility
Internal function impl_parse() is exported → Users call it → You cannot change its signature in v2 → Version bump required
Your library exports init() → Another library also has init() → Dynamic linker picks the wrong one → Silent malfunction
500 internal helpers all exported → Dynamic symbol table 500 entries larger → Linker must process all of them at load time
Technique 1: The static Keyword (File-Local Visibility)
The simplest way to hide a symbol is to mark it static. A static function or variable is visible only within the single source file where it is defined.
/* mylib_internal.c */
/* This function is VISIBLE externally — exported by default */
void public_api(void) {
/* calls the internal helper */
internal_helper();
}
/* This function is HIDDEN — only visible within this .c file */
static void internal_helper(void) {
/* implementation detail */
}
/* This variable is HIDDEN — file-local only */
static int internal_counter = 0;
Limitation: static only hides from other .c files. If your shared library is built from multiple .c files and you want a helper function accessible from several of those files but hidden from outside the library — static does not help. You need the visibility attribute instead.
| Visibility scope | static | hidden attribute |
|---|---|---|
| Same .c file | ✓ Visible | ✓ Visible |
| Other .c files in same library | ✗ Hidden | ✓ Visible |
| Programs linking the library | ✗ Hidden | ✗ Hidden |
Technique 2: GCC visibility Attribute
GCC provides a __attribute__((visibility(...))) declaration that controls symbol visibility more precisely than static.
The two main values are:
/* "default" visibility — exported, visible outside the library (this is the default) */
void __attribute__((visibility("default"))) public_function(void) {
/* ... */
}
/* "hidden" visibility — NOT exported, not visible outside the library
but CAN be used by other .c files within the same shared library */
void __attribute__((visibility("hidden"))) internal_helper(void) {
/* ... */
}
Practical usage — define macros for cleaner code:
/* visibility_macros.h */
#if defined(__GNUC__) && __GNUC__ >= 4
#define LIB_PUBLIC __attribute__((visibility("default")))
#define LIB_PRIVATE __attribute__((visibility("hidden")))
#else
#define LIB_PUBLIC
#define LIB_PRIVATE
#endif
/* math_lib.c — uses visibility macros */
#include "visibility_macros.h"
/* Public API — visible to callers of the library */
LIB_PUBLIC double fast_sqrt(double x);
LIB_PUBLIC double fast_pow(double base, double exp);
/* Internal helpers — hidden from outside, shared within library */
LIB_PRIVATE double newton_step(double x, double guess);
LIB_PRIVATE double log_approx(double x);
Alternatively, use the compiler flag to hide everything by default and only selectively expose what you want:
# Hide everything by default — requires explicit "default" for each public symbol
gcc -fvisibility=hidden -fPIC -shared -o mylib.so mylib.c
-fvisibility=hidden when building your library so everything is hidden by default. Then explicitly mark only your public API functions with __attribute__((visibility("default"))). This inverts the default and gives you opt-in exports instead of opt-out.Code Example 1: Seeing the Problem — All Symbols Exported
/* badlib.c — no visibility control — everything exported (bad practice) */
#include <stdio.h>
/* Internal helper — should NOT be public but will be */
void internal_helper(void) {
printf("[internal] helper running\n");
}
/* Another internal function */
int compute_internal(int x) {
return x * x;
}
/* Intended public API */
void public_compute(int x) {
int result = compute_internal(x);
internal_helper();
printf("Result: %d\n", result);
}
gcc -fPIC -shared -o badlib.so badlib.c
readelf --syms --use-dynamic badlib.so | grep -v UND
# Output (all three symbols are GLOBAL — visible to everyone!):
# ... FUNC GLOBAL DEFAULT ... internal_helper
# ... FUNC GLOBAL DEFAULT ... compute_internal
# ... FUNC GLOBAL DEFAULT ... public_compute
Code Example 2: Proper Visibility Control
/* goodlib.c — proper visibility control */
#include <stdio.h>
/* Mark internal functions hidden */
__attribute__((visibility("hidden")))
static void internal_helper(void) {
printf("[internal] helper running\n");
}
__attribute__((visibility("hidden")))
static int compute_internal(int x) {
return x * x;
}
/* Public API — explicitly default visibility */
__attribute__((visibility("default")))
void public_compute(int x) {
int result = compute_internal(x);
internal_helper();
printf("Result: %d\n", result);
}
gcc -fPIC -shared -o goodlib.so goodlib.c
readelf --syms --use-dynamic goodlib.so | grep -v UND
# Output (only the intended public function is visible!):
# ... FUNC GLOBAL DEFAULT ... public_compute
#
# internal_helper and compute_internal do NOT appear in the dynamic
# symbol table — they are hidden
nm -D goodlib.so — hidden symbols will not appear. Only public_compute will show with type T (defined global text symbol).Code Example 3: Using -fvisibility=hidden (Opt-in Export)
/* api.h — public API header for our library */
#define API_EXPORT __attribute__((visibility("default")))
API_EXPORT void lib_init(void);
API_EXPORT int lib_process(const char *data, int len);
API_EXPORT void lib_cleanup(void);
/* lib.c — implementation; all symbols hidden unless marked API_EXPORT */
#include <stdio.h>
#include <string.h>
#include "api.h"
/* Private state — hidden */
static int initialized = 0;
static int total_processed = 0;
/* Private helpers — hidden */
static int validate_input(const char *data, int len) {
return data != NULL && len > 0;
}
static void log_internal(const char *msg) {
fprintf(stderr, "[lib internal] %s\n", msg);
}
/* Public functions — marked API_EXPORT in api.h */
void lib_init(void) {
if (!initialized) {
log_internal("initializing");
initialized = 1;
}
}
int lib_process(const char *data, int len) {
if (!initialized) { log_internal("not initialized!"); return -1; }
if (!validate_input(data, len)) return -1;
total_processed += len;
printf("Processed %d bytes (total: %d)\n", len, total_processed);
return len;
}
void lib_cleanup(void) {
log_internal("cleanup");
initialized = 0;
total_processed = 0;
}
# Build with -fvisibility=hidden: everything is hidden UNLESS marked API_EXPORT
gcc -fPIC -fvisibility=hidden -shared -o lib.so lib.c
# Check exports — only lib_init, lib_process, lib_cleanup should appear:
nm -D lib.so | grep ' T '
# T lib_cleanup
# T lib_init
# T lib_process
# (validate_input, log_internal, initialized, total_processed NOT listed)
