← Writing

Critical Compression of Embedded Firmware: Understanding cargo size and Linker Strategies in Rust

2025-05-29 rust · embedded · linker


In embedded Rust development, minimizing the size of the compiled firmware is essential due to the limited memory resources of microcontrollers.
This article explores how to analyze binary sizes using the cargo size tool, the structure of ELF binaries produced during compilation, and the importance of linker scripts in controlling memory layout.

🟠 Focusing on the ARM Cortex-M0 target, we will discuss practical strategies to optimize and compress firmware for resource-constrained embedded systems.


    I. Introduction to cargo size and ELF binaries

    A. The role of cargo size

    B. ELF Binaries

    C. Target Architecture Context

    II. Detailed Analysis of cargo size Output

    A. Output Overview

The cargo size output is a list of sections, their sizes in bytes, and their corresponding memory addresses.

cargo size --bin blink_external_led -- -A          
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.14s
blink_external_led  :
section               size        addr
.vector_table          192  0x10000100
.boot_info              20  0x100001c0
.boot2                 256  0x10000000
.text                33044  0x100001d4
.bi_entries              0  0x100082e8
.rodata               4904  0x100082e8
.data                    0  0x20000000
.gnu.sgstubs             0  0x10009620
.bss                     4  0x20000000
.uninit                  0  0x20000004
.defmt                   3         0x0
.debug_abbrev        29892         0x0
.debug_info         737273         0x0
.debug_aranges       29600         0x0
.debug_ranges       123360         0x0
.debug_str          960891         0x0
.comment               153         0x0
.ARM.attributes         50         0x0
.debug_frame         86396         0x0
.debug_line         315121         0x0
.debug_loc            5256         0x0
.debug_pubnames        489         0x0
.debug_pubtypes         71         0x0
Total              2326975

The total size is 2326975 bytes, which is approximately 2.33 MB.
As the output shows, the dev profile was used to create the file, which includes debug information, which is the reason for the large total size of the binary.

cargo size --bin blink_external_led --release -- -A
    Finished `release` profile [optimized] target(s) in 0.15s
blink_external_led  :
section             size        addr
.vector_table        192  0x10000100
.boot_info            20  0x100001c0
.boot2               256  0x10000000
.text               6396  0x100001d4
.bi_entries            0  0x10001ad0
.rodata              888  0x10001ad0
.data                  0  0x20000000
.gnu.sgstubs           0  0x10001e60
.bss                   4  0x20000000
.uninit                0  0x20000004
.defmt                 3         0x0
.comment             153         0x0
.ARM.attributes       50         0x0
Total               7962

When creating a release version, the size is 7962 bytes just 7.9КБ or 0.007962 MB.
Which is approximately 293 times smaller than the debug version.

    B. Main memory sections (loaded into Flash/RAM)

Next we will look at an example of a release-optimized cargo size output.
These sections represent the actual code and data that will be loaded into the microcontroller’s memory for execution.

Calculating the sizes of the main loaded sections:

The .data, .bss, .uninit, and .gnu.sgstubs sections are 4 bytes in total.
This means that the actual executable code and read-only data for the blink_external_led application take up just about 8KB of Flash memory, and no static variable data is used in RAM.

    C. Debug information section (not loaded into Flash devices for execution)

.debug_abbrev        29892         0x0
.debug_info         737273         0x0
.debug_aranges       29600         0x0
.debug_ranges       123360         0x0
.debug_str          960891         0x0
.comment               153         0x0
.ARM.attributes         50         0x0
.debug_frame         86396         0x0
.debug_line         315121         0x0
.debug_loc            5256         0x0
.debug_pubnames        489         0x0
.debug_pubtypes         71         0x0

The .debug_ sections (e.g. .debug_info, .debug_line) in an ELF file contain metadata formatted according to the DWARF standard. This information is needed by debuggers such as GDB to effectively interact with and analyze the compiled program.  

Their primary purpose is to provide a comprehensive map between the compiled machine code and the source code.
During development, enabling these sections is invaluable for efficiently identifying and fixing bugs.

Enabling debug symbols will increase the size of the generated ELF file, but typically does not affect the size of the downloaded program, as the debug information is removed before being loaded onto the device.

In addition to the DWARF sections, .comment and .ARM.attributes are metadata sections that provide information about the build and architecture.

    III. Memory Sections in Embedded

    A. FLASH and RAM

ARM Cortex-M microcontrollers feature Harvard architecture or similar memory partitioning, having separate memory areas for storing programs and volatile data.

The addr values ​​in the cargo size output are not randomly. These addresses are confirmed to be the standard base addresses for FLASH and RAM on many ARM Cortex-M devices.
They correspond directly to the physical memory map of the target microcontroller.
Any attempt by the linker to place code or data outside these defined physical memory ranges will result in a linker error or, more critically, undefined behavior and system crashes at runtime if the program is flashed to the device. The cortex-m-rt crate provides default values, but customization for specific hardware variations or extended memory management units (MPUs) is often required.

    B. Stack and Heap (Runtime Memory)

While cargo size provides a static view of sections of a binary, it is critical to understand the dynamic memory components that consume RAM at runtime.

    IV. Managing Memory Layout and Section Placement (Linker Scripts)

    A. Linker Scripts Role

Linker scripts are text files that serve as configuration input to the linker.
They provide precise instructions on how to map input sections from object files to output sections in the final ELF executable.
Crucially, they also dictate where these output sections should be placed in specific memory areas (such as Flash and RAM) of the target embedded device.
While the cortex-m-rt crate provides a default linker script, for production embedded applications developers often need to customize it.

    B. Basic directives in Linker Scripts

Linker scripts use several key directives to define and control memory layout, which can be described in the memory.x file:

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;

    1. Block MEMORY

    2. EXTERN directive

3. ENTRY directive

4. SECTIONS block

First SECTIONS block for .boot2

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

Second SECTIONS block for .boot_info

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

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

Third SECTIONS block for .bi_entries

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

    SECTION Mods and Macros

    V. Strategies for Optimizing Binary File Size in Rust Embedded Projects

    A. Compiler and Linker Configuration (The Easiest and Most Effective)

These optimizations in the Cargo.toml file.

    B. Dependency management and analysis tools

    C. Micro-optimizations and code structure

    Sticker

  1. Switch to a release build: Always compile your project with cargo build --release.
  2. Configure the release profile in Cargo.toml:
    • Symbol stripping: Add strip=true to automatically strip debug symbols.
    • Size-first optimizations: Set opt-level="z" (or "s") to prioritize size optimizations.
    • Link-time optimizations (LTO): Enable lto=true for cross-module optimizations and dead code removal.
    • Code generation units: Set codegen-units=1 for maximum optimizations, though this will increase compile times.
    • Panic strategy: Consider panic="abort" to remove stack unwinding code if that suits your application’s requirements.
  3. Dependency Analysis: Use cargo-bloat to identify dependencies that contribute the most to the binary size.
  4. Understand and Manage Linker Scripts: Familiarize yourself with the linker script file (memory.x or device.x) used by your project. Optionally, customize it with MEMORY, ENTRY, SECTIONS directives, and Rust attributes like #[link_section] to fine-tune the placement of code and data in the microcontroller’s memory.
  5. Analyze Dynamic Memory Usage: In addition to the static binary size, use tools like cargo-call-stack to analyze stack usage at runtime to prevent stack overflows that could lead to system crashes. 6. Micro-optimizations: Although less significant than compiler tweaks, small changes to the code such as buffer handling optimizations, idiomatic method chaining, and efficient type conversions can further contribute to code size reduction.