Static and Shared Libraries

Libraries let you package compiled code for reuse without distributing source. The distinction between static and shared libraries affects binary size, load time, memory usage, and update strategy. This chapter covers both, plus how to bridge C and Rust libraries across the language boundary.

Static Libraries (.a)

A static library is an archive of object files. At link time, the linker copies the needed object code directly into the final executable.

Creating a Static Library in C

/* vec2.h */
#ifndef VEC2_H
#define VEC2_H

typedef struct {
    double x;
    double y;
} Vec2;

Vec2 vec2_add(Vec2 a, Vec2 b);
Vec2 vec2_scale(Vec2 v, double s);
double vec2_dot(Vec2 a, Vec2 b);

#endif
/* vec2.c */
#include "vec2.h"

Vec2 vec2_add(Vec2 a, Vec2 b) {
    return (Vec2){ a.x + b.x, a.y + b.y };
}

Vec2 vec2_scale(Vec2 v, double s) {
    return (Vec2){ v.x * s, v.y * s };
}

double vec2_dot(Vec2 a, Vec2 b) {
    return a.x * b.x + a.y * b.y;
}

Build the static library:

gcc -c -O2 vec2.c -o vec2.o
ar rcs libvec2.a vec2.o
  • ar is the archiver.
  • r inserts files into the archive (replacing if they exist).
  • c creates the archive if it does not exist.
  • s writes an index (equivalent to running ranlib).

Inspect it:

ar t libvec2.a      # list contents
nm libvec2.a        # list symbols

Linking Against a Static Library

/* main.c */
#include <stdio.h>
#include "vec2.h"

int main(void) {
    Vec2 a = {1.0, 2.0};
    Vec2 b = {3.0, 4.0};

    Vec2 sum = vec2_add(a, b);
    printf("sum = (%.1f, %.1f)\n", sum.x, sum.y);

    double d = vec2_dot(a, b);
    printf("dot = %.1f\n", d);

    Vec2 scaled = vec2_scale(a, 3.0);
    printf("scaled = (%.1f, %.1f)\n", scaled.x, scaled.y);

    return 0;
}
gcc -O2 main.c -L. -lvec2 -o vectest
./vectest
  • -L. tells the linker to search the current directory for libraries.
  • -lvec2 tells it to look for libvec2.a (or libvec2.so).

The resulting binary is self-contained -- it does not need libvec2.a at runtime.

+-------------------+       +-------------------+
|  main.o           |       |  libvec2.a        |
|  main() [T]       |       |   vec2.o:         |
|  vec2_add [U]  ---+------>|     vec2_add [T]  |
|  vec2_dot [U]  ---+------>|     vec2_dot [T]  |
+-------------------+       +-------------------+
         \                         /
          \                       /
           v                     v
        +---------------------------+
        |  vectest (executable)     |
        |  main()                   |
        |  vec2_add()  (copied in)  |
        |  vec2_dot()  (copied in)  |
        +---------------------------+

Caution: Static linking copies code into every executable that uses it. If ten programs link libvec2.a, each gets its own copy. Security patches to the library require recompiling all ten programs.

Shared Libraries (.so)

A shared library is loaded at runtime. Multiple programs can share a single copy in memory.

Creating a Shared Library

gcc -c -O2 -fPIC vec2.c -o vec2_pic.o
gcc -shared -o libvec2.so vec2_pic.o
  • -fPIC generates position-independent code, required for shared libs.
  • -shared tells the linker to produce a shared object.

Linking Against a Shared Library

gcc -O2 main.c -L. -lvec2 -o vectest_shared

But running it may fail:

./vectest_shared
# error: libvec2.so: cannot open shared object file

The dynamic linker does not search the current directory by default. Solutions:

# Option 1: Set LD_LIBRARY_PATH
LD_LIBRARY_PATH=. ./vectest_shared

# Option 2: Install to a system path
sudo cp libvec2.so /usr/local/lib/
sudo ldconfig

# Option 3: Embed the path at link time
gcc -O2 main.c -L. -lvec2 -Wl,-rpath,'$ORIGIN' -o vectest_shared

The -Wl,-rpath,'$ORIGIN' approach embeds a relative search path in the binary itself. $ORIGIN expands to the directory containing the executable.

Runtime vs. Compile Time

+-----------------------+
|   Compile/Link Time   |
|-----------------------|
|  gcc finds libvec2.so |
|  records dependency   |
|  does NOT copy code   |
+-----------------------+
         |
         v
+-----------------------+
|   Runtime             |
|-----------------------|
|  ld.so loads .so      |
|  maps into memory     |
|  resolves symbols     |
+-----------------------+

Check what shared libraries an executable needs:

ldd vectest_shared

Soname Versioning

Shared libraries use a versioning scheme:

libvec2.so.1.2.3      # real name (major.minor.patch)
libvec2.so.1          # soname (major version)
libvec2.so            # linker name (symlink)
gcc -shared -Wl,-soname,libvec2.so.1 -o libvec2.so.1.0.0 vec2_pic.o
ln -s libvec2.so.1.0.0 libvec2.so.1
ln -s libvec2.so.1 libvec2.so

The executable records the soname (libvec2.so.1), not the full version. This means you can update libvec2.so.1.0.0 to libvec2.so.1.1.0 without relinking executables, as long as the ABI is compatible.

readelf -d vectest_shared | grep NEEDED
# 0x0000000000000001 (NEEDED)  Shared library: [libvec2.so.1]

The ldconfig command manages the soname symlinks system-wide. Run sudo ldconfig after installing a new library to update the cache.

dlopen / dlsym: Runtime Loading

Sometimes you need to load a library at runtime -- for plugins, optional features, or late binding.

Define a plugin with a clean ABI:

/* plugin_api.h */
#ifndef PLUGIN_API_H
#define PLUGIN_API_H

int plugin_init(void);
int plugin_process(int input);
void plugin_cleanup(void);

#endif
/* my_plugin.c */
#include <stdio.h>
#include "plugin_api.h"

int plugin_init(void) {
    printf("[plugin] initialized\n");
    return 0;
}

int plugin_process(int input) {
    return input * 3 + 1;
}

void plugin_cleanup(void) {
    printf("[plugin] cleaned up\n");
}
gcc -shared -fPIC -o my_plugin.so my_plugin.c
/* host.c */
#include <stdio.h>
#include <dlfcn.h>

typedef int (*init_fn)(void);
typedef int (*process_fn)(int);
typedef void (*cleanup_fn)(void);

int main(int argc, char *argv[]) {
    const char *plugin_path = (argc > 1) ? argv[1] : "./my_plugin.so";

    void *handle = dlopen(plugin_path, RTLD_LAZY);
    if (!handle) {
        fprintf(stderr, "dlopen: %s\n", dlerror());
        return 1;
    }

    init_fn    init    = (init_fn)dlsym(handle, "plugin_init");
    process_fn process = (process_fn)dlsym(handle, "plugin_process");
    cleanup_fn cleanup = (cleanup_fn)dlsym(handle, "plugin_cleanup");

    if (!init || !process || !cleanup) {
        fprintf(stderr, "dlsym: %s\n", dlerror());
        dlclose(handle);
        return 1;
    }

    init();
    printf("process(10) = %d\n", process(10));
    cleanup();

    dlclose(handle);
    return 0;
}
gcc -o host host.c -ldl
./host ./my_plugin.so

Output:

[plugin] initialized
process(10) = 31
[plugin] cleaned up

Link with -ldl to get dlopen / dlsym / dlclose.

Driver Prep: The Linux kernel's module system is conceptually similar to dlopen. When you run insmod mydriver.ko, the kernel loads the module's ELF object, resolves symbols against the kernel's exported symbol table, and calls the module's init function.

Rust Library Types

Rust supports several library output types, configured in Cargo.toml:

[lib]
crate-type = ["rlib"]       # default: Rust-native library
# crate-type = ["staticlib"]  # C-compatible static library (.a)
# crate-type = ["cdylib"]     # C-compatible shared library (.so)
# crate-type = ["dylib"]      # Rust-native shared library
TypeFileUse case
rlib.rlibDependency for other Rust crates
staticlib.aLink into a C/C++ program
cdylib.soShared lib callable from C
dylib.soShared lib for other Rust code

Building a Rust Static Library for C

# Cargo.toml
[package]
name = "rustmath"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["staticlib"]
#![allow(unused)]
fn main() {
// src/lib.rs
use std::os::raw::c_int;

#[no_mangle]
pub extern "C" fn rust_add(a: c_int, b: c_int) -> c_int {
    a + b
}

#[no_mangle]
pub extern "C" fn rust_factorial(n: c_int) -> c_int {
    if n <= 1 { 1 } else { n * rust_factorial(n - 1) }
}
}
cargo build --release
ls target/release/librustmath.a

Now call it from C:

/* use_rustlib.c */
#include <stdio.h>
#include <stdint.h>

/* Declarations matching Rust's extern "C" functions */
int32_t rust_add(int32_t a, int32_t b);
int32_t rust_factorial(int32_t n);

int main(void) {
    printf("rust_add(10, 20) = %d\n", rust_add(10, 20));
    printf("rust_factorial(6) = %d\n", rust_factorial(6));
    return 0;
}
gcc -O2 use_rustlib.c -L target/release -lrustmath -lpthread -ldl -lm -o use_rustlib
./use_rustlib

The extra -lpthread -ldl -lm flags are needed because Rust's standard library depends on them.

Rust Note: When producing a staticlib, Rust statically links its own standard library into the .a file. This makes the archive self-contained but larger. A cdylib dynamically links the Rust standard library.

Building a Rust Shared Library for C

Change the crate type to ["cdylib"] and rebuild. This produces a librustmath.so that can be dynamically linked from C the same way.

Writing a C Library Callable from Rust

The reverse direction: wrap an existing C library for use in Rust.

/* cstack.h */
#ifndef CSTACK_H
#define CSTACK_H

#include <stdint.h>
#include <stdbool.h>

#define STACK_CAPACITY 64

typedef struct {
    int32_t data[STACK_CAPACITY];
    int32_t top;
} Stack;

void stack_init(Stack *s);
bool stack_push(Stack *s, int32_t value);
bool stack_pop(Stack *s, int32_t *out);
int32_t stack_size(const Stack *s);

#endif
/* cstack.c */
#include "cstack.h"

void stack_init(Stack *s) {
    s->top = -1;
}

bool stack_push(Stack *s, int32_t value) {
    if (s->top >= STACK_CAPACITY - 1) return false;
    s->data[++(s->top)] = value;
    return true;
}

bool stack_pop(Stack *s, int32_t *out) {
    if (s->top < 0) return false;
    *out = s->data[(s->top)--];
    return true;
}

int32_t stack_size(const Stack *s) {
    return s->top + 1;
}

Use it from Rust with the cc crate:

# Cargo.toml
[package]
name = "use-cstack"
version = "0.1.0"
edition = "2021"

[build-dependencies]
cc = "1"
// build.rs
fn main() {
    cc::Build::new()
        .file("cstack.c")
        .compile("cstack");
}
// src/main.rs
use std::os::raw::c_int;

const STACK_CAPACITY: usize = 64;

#[repr(C)]
struct Stack {
    data: [c_int; STACK_CAPACITY],
    top: c_int,
}

extern "C" {
    fn stack_init(s: *mut Stack);
    fn stack_push(s: *mut Stack, value: c_int) -> bool;
    fn stack_pop(s: *mut Stack, out: *mut c_int) -> bool;
    fn stack_size(s: *const Stack) -> c_int;
}

fn main() {
    unsafe {
        let mut s = std::mem::MaybeUninit::<Stack>::uninit();
        stack_init(s.as_mut_ptr());
        let mut s = s.assume_init();

        stack_push(&mut s, 10);
        stack_push(&mut s, 20);
        stack_push(&mut s, 30);

        println!("size = {}", stack_size(&s));

        let mut val: c_int = 0;
        while stack_pop(&mut s, &mut val) {
            println!("popped: {}", val);
        }
    }
}
cargo run

Output:

size = 3
popped: 30
popped: 20
popped: 10

Caution: When defining #[repr(C)] structs in Rust to match C structs, you must get the field order, types, and sizes exactly right. A mismatch causes silent memory corruption. Use bindgen to generate these automatically for anything non-trivial.

ABI Compatibility

ABI (Application Binary Interface) defines how functions pass arguments, return values, and lay out structs at the machine level. On x86-64 Linux, the System V AMD64 ABI passes the first six integer arguments in registers RDI, RSI, RDX, RCX, R8, R9. Return values go in RAX.

  +--------+--------+--------+--------+--------+--------+
  | Arg 1  | Arg 2  | Arg 3  | Arg 4  | Arg 5  | Arg 6  |
  | RDI    | RSI    | RDX    | RCX    | R8     | R9     |
  +--------+--------+--------+--------+--------+--------+
  | Remaining args go on the stack, right to left        |
  +------------------------------------------------------+

When Rust uses extern "C", it follows this exact convention.

Rust Note: Rust's native ABI is not stable and can change between compiler versions. Always use extern "C" when crossing language boundaries. The #[no_mangle] attribute prevents Rust from mangling the symbol name, making it findable by C code.

Knowledge Check

  1. What is the difference between ar rcs libfoo.a foo.o and gcc -shared -o libfoo.so foo.o?

  2. An executable built against libvec2.so.1 fails to run after you update the library. What might have changed?

  3. Why must you compile with -fPIC before creating a shared library?

Common Pitfalls

  • Forgetting -fPIC. Without position-independent code, the shared library cannot be loaded at arbitrary addresses. The linker will error.

  • Library search order confusion. The linker prefers .so over .a when both exist. Use -static or pass the .a path directly to force static linking.

  • Missing transitive dependencies. If libA.so depends on libB.so, you may need to link both explicitly. Use pkg-config or CMake's target_link_libraries to manage this.

  • Forgetting -ldl for dlopen. On glibc systems, dlopen and dlsym live in libdl. Link with -ldl.

  • ABI mismatch between C and Rust structs. If you define a struct in both languages, the layout must match exactly. Use #[repr(C)] in Rust and verify with offsetof / std::mem::offset_of!.

  • Stripping symbols from a shared library. Stripping all symbols from a .so makes it useless. Use strip --strip-unneeded to keep only the dynamic symbols.