Understanding Rust FFI: Symbol Declaration, Address Resolution, and Linking Models

Rust’s Foreign Function Interface operates on the same compilation and linking principles as C and C++, with the unsafe keyword serving as the primary boundary marker. When developing bare-metal systems without the standard library, developers must manually manage the execution environment, including the program entry point and initial stack allocation. This process typically involves coordinating assembly initialization routines, linker memory layouts, and Rust source code.

# arch/entry.S
.section .init
.global kernel_init
.type kernel_init, @function

kernel_init:
    la sp, stack_ptr_top
    jal ra, execute_kernel

    .section .stack_area
stack_ptr_base:
    .space 4096 * 16
stack_ptr_top:

This initialization routine establishes a dedicated initialization section, exports a global entry symbol, configures the stack pointer, and transfers control to the primary Rust routine. A reserved memory block is allocated in the .stack_area section to serve as the initial execution stack.

OUTPUT_ARCH("riscv")
ENTRY(kernel_init)
ORIGIN = 0x80200000;

MEMORY
{
    RAM : ORIGIN = 0x80200000, LENGTH = 128M
}

SECTIONS
{
    . = ORIGIN;
    _init_start = .;

    .init : ALIGN(4K) {
        *(.init)
        *(.text.*)
    }
    _init_end = .;

    .rodata : ALIGN(4K) {
        *(.rodata*)
        *(.srodata*)
    }

    .data : ALIGN(4K) {
        *(.data*)
        *(.sdata*)
    }

    .bss : ALIGN(4K) {
        *(.stack_area)
        _bss_start = .;
        *(.bss*)
        *(.sbss*)
        _bss_end = .;
    }
    /DISCARD/ : {
        *(.comment)
        *(.eh_frame)
    }
}

The linker configuration orchestrates object file merging, resolves symbol locations, and enforces a strict memory mapping. It ensures the initialization code occupies the lowest address space, aligns sections to page boundaries, and exposes boundary markers for the uninitialized data region.

#![no_std]
#![no_main]

core::arch::global_asm!(include_str!("entry.S"));

#[unsafe(no_mangle)]
fn execute_kernel() -> ! {
    zero_initialize_bss();
    loop {}
}

fn zero_initialize_bss() {
    extern "C" {
        fn _bss_start();
        fn _bss_end();
    }

    let begin = _bss_start as usize;
    let end = _bss_end as usize;

    if end > begin {
        unsafe {
            let region = core::slice::from_raw_parts_mut(begin as *mut u8, end - begin);
            region.fill(0);
        }
    }
}

The implementation leverages compiler directives to embed the assembly routine and suppress name mangling for the primary function. The BSS clearing routine demonstrates a typical FFI pattern: external symbols are imported using the C ABI, treated as function pointers, cast to integer addresses, and utilized to define a mutable byte slice for zero-initialization.

The mechanism behind zero_initialize_bss() highlights fundamental characteristics of cross-language boundary interaction. During the compilation phase, the Rust frontend treats imported symbols as opaque external references, emitting relocation entries rather than concrete values. The actual memory addresses are assigned exclusively by the linker during the object combination phase. Consequently, symbol values in statically linked binaries represent resolved absolute or relative addresses embedded dircetly into the final executable.

Declaring linker symbols as extern "C" fn rather than static types addresses a type system constraint: the linker operates purely on addresses and memory segments, providing zero metadata regarding data layout or types. Rust’s type checker, however, requires a valid signature for every external reference. Using a zero-argument function signature serves as a minimal, ABI-compliant placeholder that compiles to an immediate address fetch when cast to usize. This pattern enforces a strict separation between address representation and memory interpretation. The runtime does not invoke these placeholders; it merely treats them as pointers.

While static start_bss: usize; achieves identical address resolution, the function placeholder remains the community standard for linker script symbols. The compiler internally differentiates between code pointers (.text) and data references (.data/.bss), but the backend code generator and linker optimize both into identical immediate value loads at runtime.

Understanding symbol resolution extends beyond static embedding. In dynamic linking scenarios, binaries reference shared libraries without embedding their contents. The compilation phase only records symbol names and generates Position Independent Code (PIC) or relies on Global Offset Tables (GOT) and Procedure Linkage Tables (PLT). At program startup, the operating system’s dynamic loader maps the shared objects into memory and patches the GOT/PLT entries with actual runtime addresses. The FFI boundary behavior remains consistent: Rust code interacts with resolved function pointers, abstracting away the underlying relocation mechanics.

For dynamic library loading at runtime, applications utilize system APIs to map shared objects into the process address space on demand. The returned symbol handles are cast to appropriate Rust function signatures. Regardless of the loading strategy, the fundamental rule persists: FFI boundaries transmit raw memory addresses, and correct type interpretation remains the explicit responsibility of the calling code.

Tags: rust FFI Linker Scripts bare-metal System Programming

Posted on Thu, 27 Aug 2026 16:50:07 +0000 by RussW