How the Compiler Decides What to Inline or Optimize in Embedded Code
Why some functions vanish into thin air, others stubbornly remain, and how Rust + LLVM quietly rewrite your firmware behind your back.
How the Compiler Decides What to Inline or Optimize in Embedded Code
Why some functions vanish into thin air, others stubbornly remain, and how Rust + LLVM quietly rewrite your firmware behind your back.
There’s a moment every embedded developer hits sooner or later.
You write a clean helper function.
You mark it #[inline].
You build your firmware.
You open the disassembly.
…and the function is still there.
Then another function — one you didn’t care about at all — completely disappears.
No call. No symbol. No trace.
That’s when it hits you:
Inlining and optimization aren’t promises. They’re negotiations.
And the compiler is the one holding all the leverage.
This article is about how the Rust compiler actually decides what to inline, remove, reorder, or brutally optimize away when you’re writing embedded or no_std code — and why its decisions are often smarter than a human with a stopwatch.
First: A Hard Truth About Embedded Optimization
Most people think optimization works like this:
“I write code → compiler makes it faster.”
Reality is harsher and more interesting:
The compiler builds a cost model, simulates multiple futures, and chooses the version that best fits the target CPU, memory layout, and call graph.
Especially in embedded.
Inlining isn’t about speed alone. It’s about:
- code size
- instruction cache pressure
- branch prediction
- register pressure
- stack usage
- pipeline depth
- call frequency
- target architecture quirks
On a microcontroller, saving 8 bytes can matter more than saving 2 cycles.
The Big Picture: Who Actually Decides?
Let’s get this straight first.
Rust does not decide inlining.
LLVM does.
Rust:
- parses your code
- enforces ownership
- monomorphizes generics
- lowers everything to MIR
- hands it to LLVM
LLVM:
- builds the call graph
- estimates costs
- runs inlining heuristics
- performs all real optimizations
So when we say “the compiler decided”, we really mean:
Rust prepared the battlefield. LLVM chose who lives.
The Embedded Compilation Pipeline (Mental Model)
Rust Source
|
v
HIR (High-level IR)
|
v
MIR (ownership, moves, drops)
|
v
LLVM IR (pure SSA, no Rust rules)
|
v
LLVM Optimizer
├─ Inliner
├─ Const Prop
├─ DCE
├─ Loop Opt
├─ Register Alloc
|
v
Target Backend (ARM / RISC-V)
|
v
Machine Instructions
Inlining decisions happen inside LLVM, after Rust is already gone.
That’s important.
Example 1: The Function That Vanishes
#![no_std]
fn square(x: u32) -> u32 {
x * x
}
pub fn compute(v: u32) -> u32 {
square(v) + 1
}
You might expect a function call.
But LLVM sees:
squareis small- used once
- pure (no side effects)
- argument is a register
- return value used immediately
So it rewrites compute as:
pub fn compute(v: u32) -> u32 {
v * v + 1
}
Assembly (ARM Cortex-M):
mul r0, r0, r0
adds r0, r0, #1
bx lr
No call.
No stack.
No symbol for square.
This isn’t “aggressive optimization”. This is the default behavior.
How LLVM Decides to Inline (The Real Rules)
LLVM uses a cost model.
Not one rule — a weighted equation.
Some of the biggest factors:
1. Function size
Smaller functions are easier to inline.
Tiny arithmetic helpers? Gone.
Large state machines? Probably not.
2. Call frequency
If a function is:
- called once → likely inline
- called in a loop → maybe inline
- called from many places → less likely
Inlining duplicates code. On embedded, code size matters.
3. Target architecture
Inlining rules change depending on:
- ARM Cortex-M0 vs M7
- RISC-V
- AVR
- instruction cache size
- pipeline depth
On small MCUs, LLVM is more conservative.
4. Register pressure
Inlining increases live variables.
If inlining would spill registers to the stack, LLVM often refuses.
Stack access is expensive on MCUs.
5. Inlining enables other optimizations
This one is subtle but huge.
Inlining is often done not for speed directly, but because it enables:
- constant propagation
- dead code elimination
- branch removal
- loop unrolling
Inlining is a gateway drug.
Example 2: Inlining Enables Constant Folding
fn scale(x: u32, factor: u32) -> u32 {
x * factor
}
pub fn compute() -> u32 {
scale(10, 4)
}
After inlining:
pub fn compute() -> u32 {
10 * 4
}
After constant folding:
pub fn compute() -> u32 {
40
}
Assembly:
movs r0, #40
bx lr
The entire computation disappears.
Without inlining, this wouldn’t happen.
Why #[inline] Is Only a Hint
Let’s be honest:
#[inline] is emotionally comforting — but weak.
#[inline]
fn helper(x: u32) -> u32 {
x + 1
}
What it really means:
“Hey LLVM, this might be a good idea.”
LLVM can still say no.
#[inline(always)]
This is stronger — but still not absolute.
LLVM can still refuse if:
- it would break correctness
- it would explode code size
- it would violate ABI rules
There is no absolute force-inline in Rust embedded code.
And that’s intentional.
Example 3: Why Big Functions Don’t Inline
fn process(data: &[u8]) -> u32 {
let mut sum = 0;
for b in data {
sum += *b as u32;
}
sum
}
Inlining this into multiple call sites would:
- duplicate loops
- increase flash size
- increase register pressure
- increase I-cache pressure
LLVM usually keeps this as a call — even in embedded.
Speed isn’t everything. Predictability and size matter more.
Dead Code Elimination: The Most Brutal Optimization
This one surprises people.
fn debug_led() {
// toggle GPIO
}
pub fn main_loop() {
// debug_led(); // commented out
}
debug_led will not exist in the binary.
Not “unused”. Not “inactive”.
Gone.
Why?
Because Rust + LLVM see:
- no references
- no side effects (from the compiler’s POV)
- no exports
In embedded builds, dead code elimination is ruthless.
This is why unused drivers, helpers, and abstractions simply vanish.
Ownership Helps Optimization (This Is the Secret)
Rust’s ownership rules give LLVM superpowers.
Example:
fn write(buf: &mut [u8]) {
buf[0] = 1;
buf[1] = 2;
}
LLVM knows:
bufhas no aliases- nothing else can touch it
- no hidden pointers exist
In C, LLVM must assume aliasing.
In Rust, it doesn’t.
That single fact enables:
- better reordering
- better vectorization
- fewer loads
- fewer stores
- tighter code
This is huge in embedded.
Code Flow Diagram: Optimization Decision
Call Site
|
v
Is function small?
|
+-- No --> Keep call
|
Yes
|
Is call hot?
|
+-- No --> Maybe inline
|
Yes
|
Would inlining:
- reduce branches?
- enable const-prop?
- avoid stack?
|
+-- Yes --> Inline
|
No --> Keep call
This happens thousands of times per build.
The Emotional Side (Yes, It Matters)
At first, this feels frustrating.
You want control. You want certainty. You want this function inlined and that one untouched.
But after enough firmware bugs… after enough stack overflows… after enough “why did this slow down?” moments…
You realize something:
The compiler is calmer than you are.
It doesn’t panic. It doesn’t guess. It doesn’t cargo-cult.
It measures. It simulates. It chooses.
And most of the time — especially in embedded — it chooses better than we would.
Final Thoughts
Inlining and optimization in embedded Rust are not magic.
They are:
- cost models
- architecture awareness
- ownership-driven guarantees
- ruthless dead code elimination
- decades of LLVM tuning
When Rust compiles your firmware, it’s not “helping you a little”.
It’s re-authoring your program into something smaller, tighter, and more predictable than hand-written C.
Once you trust that process, embedded Rust stops feeling restrictive.
It starts feeling like having a senior compiler engineer sitting next to you — silently fixing your mistakes.
메타데이터
- post_id
- 9032b7f91cbd
- slug
- how-the-compiler-decides-what-to-inline-or-optimize-in-embedded-code-9032b7f91cbd
- url
- https://medium.com/@theopinionatedev/how-the-compiler-decides-what-to-inline-or-optimize-in-embedded-code-9032b7f91cbd
- canonical_url
- https://medium.com/@theopinionatedev/how-the-compiler-decides-what-to-inline-or-optimize-in-embedded-code-9032b7f91cbd
- author_url
- https://medium.com/@theopinionatedev
- status
- ok
- fetched_at
- 2026-07-13 12:20:31