← Writing

From Rust Source to Embedded Executable: A Deep Dive into the Compilation Process for Embedded Systems

2025-06-05 rust · compilers · embedded


This article is the second part of my exploration into the stages and optimizations of Rust compilation, focusing now on embedded systems development.
Embedded environments impose unique constraints such as limited memory, lack of an operating system, and strict real-time requirements.
Consequently, compiling Rust code for these systems demands special considerations — from the removal of the standard library to precise target specifications and custom linker scripts.

🟠 Here, we’ll walk through the Rust compilation pipeline with a focus on how it adapts for bare-metal, resource-constrained embedded platforms.


    I. Initial code processing and abstract representation

The initial stages of Rust compilation, including lexical analysis, parsing, and building an abstract syntax tree (AST), remain largely unchanged for embedded systems.

A. Lexing: Tokenizing the source code

B. Parsing: Building an abstract syntax tree (AST)

C. Early Semantic Passes: Macro Expansion and Name Resolution

    II. HIR and semantic analysis

This stage focuses on desugaring syntactic constructs and performing basic semantic analyses, including type checking, type inference, and trait resolution.

A. Transformation to HIR: desugaring Syntactic Constructs

B. Enforcing Types: Type Inference, Type Checking, and Trait Resolution

C. Special considerations for embedded systems: no_std One of the most significant differences in embedded development in Rust is the use of the #![no_std] attribute.

    III. MIR and Basic Security Checks

A. MIR Conversion: Control Flow Graph Generation

B. Borrow Checker: Memory Safety Enforcement

C. MIR Optimizations

    IV. Code generation and machine-specific optimizations

This final major stage of the rustc compiler involves translating the highly optimized MIR into executable machine code, using LLVM for architecture-specific transformations and extensive optimizations.

A. Monomorphization and LLVM IR Generation

This allows rustc to generate code specific to the target microcontroller, including the correct calling conventions and instruction set.
Size optimization: Monomorphization, while beneficial for performance, can lead to code bloat, which is a serious problem for memory-constrained embedded systems. You should be careful about using generics and consider using size optimization flags.

B. LLVM Optimizations These optimizations are designed to improve the performance and efficiency of the generated code, including dead code removal, constant propagation, function inlining, and loop optimizations.

C. Generating Machine Code and Object Files The output of this stage is platform-specific assembly code, which is then assembled into object files (.o or .obj). The object files contain machine code, data, relocation information, and symbol tables.

    V. Linking: Building the final executable file

The final step of the compilation process involves combining all the compiled object files and required libraries into a single executable binary or shared library. This is done by the linker.

A. Static vs. Dynamic Linking Embedded systems almost always use static linking (Resolution occurs during compilation. All required library code is built directly into the final executable). This is because most embedded systems do not have an operating system capable of managing dynamic libraries and require the binary to be completely self-contained. Static linking ensures that all required dependencies are included in a single executable, simplifying deployment to the target hardware.

B. The Role of the Linker

Merging Artifacts: The primary role of the linker is to combine the generated object files, static libraries, and dynamic libraries into the final executable.
It resolves any remaining symbol references and performs relocations. rust-lld is the Rust-shipped copy of lld (the LLVM linker), used by default for bare-metal and WebAssembly targets.

MEMORY {
    BOOT2 : ORIGIN = 0x10000000, LENGTH = 0x100
    FLASH : ORIGIN = 0x10000100, LENGTH = 2048K - 0x100
    RAM : ORIGIN = 0x20000000, LENGTH = 256K
    SRAM4 : ORIGIN = 0x20040000, LENGTH = 4k
    SRAM5 : ORIGIN = 0x20041000, LENGTH = 4k
}

EXTERN(BOOT2_FIRMWARE)

SECTIONS {
    .boot2 ORIGIN(BOOT2) :
    {
        KEEP(*(.boot2));
    } > BOOT2
} INSERT BEFORE .text;

SECTIONS {
    .boot_info : ALIGN(4)
    {
        KEEP(*(.boot_info));
    } > FLASH

} INSERT AFTER .vector_table;
_stext = ADDR(.boot_info) + SIZEOF(.boot_info);

SECTIONS {
    .bi_entries : ALIGN(4)
    {
        __bi_entries_start = .;
        KEEP(*(.bi_entries));
        . = ALIGN(4);
        __bi_entries_end = .;
    } > FLASH
} INSERT AFTER .text;

This script defines the memory map of the target microcontroller (e.g. Flash, RAM, stack, heap locations) and tells the linker where to place the various code and data sections (e.g. .text, .data, .bss).
Without such a script, the linker will not be able to correctly place the program in the device’s memory.

Custom entry point (#![no_main], #[entry]): Embedded Rust programs do not use the standard main function as an entry point, since it depends on the OS runtime.
Instead, the #![no_main] attribute is used to disable the standard entry point.
The developer then defines a custom entry point, often using the #[entry] macro from an architecture-specific crate (e.g. cortex-m-rt).
This startup code is responsible for initializing the stack, data sections, and jumping to the main logic of the program.

C. Final executable file

Output format: By default, rustc generates an ELF file, which contains a lot of useful debugging information.
However, most microcontrollers require a very minimal binary (.bin) or hexadecimal (.hex) file consisting only of the program instructions. Tools such as cargo-binutils (which provides objcopy) are used to convert the ELF file to these formats.

    Sticker

Compiling a Rust program for embedded systems follows the same fundamental multi-step process as for regular applications, but with critical adaptations.  
1. removal of the standard library (no_std)
2. target-specific triplets,
3. careful optimization of code size,
4. custom linker scripts
5. custom entry points.