Exported Symbols in Embedded Libraries: Keep Internal Functions Out of the Public ABI

Define an explicit allowlist of externally bindable symbols, build the library with that policy, and compare the resulting dynamic definitions against the list. A public header alone does not prevent unrelated global functions from becoming part of an ELF shared library’s exported surface. Once consumers bind to an accidental export, removing it can become a compatibility problem.

This guide pins GCC 14.2.0 and GNU BFD Binutils 2.44 for Linux ELF shared objects. An executed x86-64 host fixture compared broad exports, hidden-default compilation and an anonymous linker version script. It does not qualify Windows DLL exports, Zephyr loadable extensions or a target’s complete binary ABI.

Write the intended surface before selecting a flag

List public functions and any deliberately public data symbols with exact linkage names, declaration ownership and compatibility policy. Include entry points discovered by name through a plugin loader; they may lack an ordinary static reference but still belong to the runtime contract.

For C++, retain both raw mangled names and a demangled review display. The friendly display helps a human, while the raw name identifies what the linker binds. Also record exported type visibility where objects, exceptions or RTTI cross shared-library boundaries. A function-only list is insufficient for an interface that exposes C++ types.

Separate required exports from undefined imports. A shared object can contain dynamic symbols it needs another object to provide. Those are dependencies, not accidental definitions. A useful audit records symbol name, defined/undefined state, binding, visibility, type, size where meaningful and version information if present.

An explicit public API is enforced by compiler and linker visibility policy, checked against final dynamic definitions and validated with public and private consumer outcomes.
Original export-policy pipeline. Undefined imports and local/debug symbols are reviewed separately from exported definitions.

Make private visibility the default deliberately

The GCC 14.2 code-generation reference documents -fvisibility=hidden and explicit default-visibility annotations. For a small C library, compiling definitions hidden by default and marking only the public declarations with visibility(“default”) is a practical construction.

Keep the annotation in the maintained public interface and ensure the definition sees the intended declaration. Do not add an export annotation only to a consumer’s copy of a header and assume it changes a prebuilt library. Verify the actual object and final shared-library outputs.

Review third-party headers before applying visibility pragmas broadly. The compiler documentation warns that outside headers may assume ordinary visibility. It also distinguishes extern declarations from definition defaults and calls out C++ cross-DSO exception/type-info implications. Hiding every symbol indiscriminately can break a valid interface rather than improve it.

Add a linker-side allowlist where appropriate

The Binutils 2.44 linker manual supports an anonymous version-script node that selects global and local symbols without assigning symbol versions. A compact illustrative policy is:

{
    global: api_value;
    local: *;
};

This fixture exports one named entry and localizes everything else eligible under the linker policy. It is distinct from authoring a hierarchy of versioned ABI nodes. Keep that larger compatibility mechanism separate from the immediate question of which definitions are public.

A linker script is not a substitute for an accurate source declaration. Compiler visibility can affect generated references and type handling before link time. Conversely, source annotations do not necessarily control every precompiled object pulled into the final DSO. Using both layers can be appropriate, but test their combined result rather than assuming either list overrides every prior decision.

Inspect the dynamic definitions, then test consumers

The GNU binary utilities manual distinguishes ordinary and dynamic symbol inspection. For trusted artifacts, the following are read-only inspection templates:

nm -D --defined-only libexample.so
readelf --dyn-syms --wide libexample.so
readelf --version-info libexample.so

Do not compare only a full unstripped symbol table with a desired API list. Internal local/debug names can remain in that table without being dynamically bindable exports. Likewise, stripping a file is not a controlled ABI policy; it should not be the first mechanism used to decide public interface membership.

Normalize the inspection into a stable manifest, then classify differences. An unexpected addition may expose an internal helper. A removal may break an existing consumer. A binding, visibility, type or version change deserves review even if the name remains present. Size changes in exported data require special attention, but equal symbol size does not prove equal semantic layout.

Executed export and negative-link fixture

The original C fixture defined api_value returning seven and helper_value returning nine. A public-visibility annotation was placed on api_value. GCC 14.2.0 and BFD ld 2.44 built three shared libraries from that source: ordinary default visibility, hidden-default compilation, and a linker allowlist applied to ordinary compilation.

Build Observed defined dynamic functions Consumer outcome
Ordinary visibility api_value and helper_value Public API consumer passed.
Hidden default plus public annotation api_value only Public API consumer passed; private-helper consumer failed to link.
Anonymous linker allowlist api_value only Public API consumer passed.

These are actual host observations from synthetic code, not measurements of an Obeita product. The negative link proves the hidden helper was unavailable to that direct consumer. It does not prove the helper’s machine code disappeared from the library or that the library is a security sandbox.

Check dynamic lookup and language boundaries

Add a test that loads every supported named plugin entry through the application’s real lookup path. A public function can disappear accidentally if the allowlist was generated only from direct link references. Keep exact spelling and version expectations with that test.

For a C++ API, test the supported exception, RTTI and ownership paths across DSOs before changing type visibility. Exposing a C wrapper with opaque handles can reduce some binary-surface complexity, but it still needs explicit allocation, destruction and lifetime rules. Visibility does not validate a function prototype or prevent consumers from misusing a handle.

Run the same export-manifest comparison after static dependencies, optimization options or feature selections change. A new object can add globals even when the public header is untouched. Keep allowed additions reviewed instead of automatically replacing the baseline with every successful build.

The release evidence is a narrow declared export set, a matching dynamic-definition manifest and positive/negative consumer tests. That makes internal refactoring possible without silently enlarging the binary contract, while preserving the entry points that supported consumers actually require.

Similar Posts