Nine Rules for Compile-Time Work with Rust const fn (Part 1)
Parse files, build tables, and catch mistakes … without a build script

Your Rust program can load and transform images, compress audio clips, and construct robot-arm programs in zero (run) time and with zero (heap) memory.
The key is const fn: Rust functions the compiler can evaluate during compilation. The compiler will also discard the original inputs and intermediate results, leaving only the final values in your compiled program. Bad inputs? The build will fail fast. Good inputs? You get a value that is validated, exactly sized, and ready to use the moment your program runs.
How far can we take const fn, and when should we stop? This article tries to answer with nine rules and examples drawn from my work on two Rust crates.
The crates are:
- Device Envoy: aims to provide the easiest path to application-level programming on bare-metal ESP32-family and Raspberry Pi Pico microcontrollers. It builds on Embassy and provides high-level abstractions for displays, touchscreens, audio players, LED panels, buttons, Wi-Fi, and other devices. It includes new support for the Cheap Yellow Display (CYD).
- Linkage Blaze: a new crate that describes and animates three-dimensional jointed mechanisms in Rust. Its compact language can construct models of robot arms, clocks, skeletons, and other articulated structures that run in browsers, on microcontrollers, and on the desktop.
This article’s examples focus on embedded no_std systems, where memory is scarce and a filesystem may not exist. Programs that use std can benefit too: const fn keeps validation, transformation, and construction code in the program while moving the execution of that code out of runtime and into compilation.
The rules and examples are:
- Process external files in const fn: no build.rs, no procedural macros, no runtime processing.
Example: The compiler sums an included file before the program starts. - Process simple file formats at compile time. Validate the input, transform its contents, and retain only the result.
Example: A clockface image becomes validated, display-ready pixels for CYD Skeleton Clock. - Make two compile-time passes with a declarative macro. First derive sizing and other constants, then use them to construct an exactly sized value.
Example: The compiler discovers the size of a NASA audio clip, then constructs its exact-sized Rust value. - Use const fn to compose large values from small building blocks instead of listing every element.
Example: Stack and rotate two serpentine LED-panel layouts to produce one complete display layout.
To be covered in Part 2:
- 5. Use const fn to replace repeated runtime calculations with lookup tables.
Example: Precompute a lookup table to provide gamma correction and runtime power limiting for an animated LED strip. - 6. Use const fn to construct programs in domain-specific languages.
Example: Linkage Blaze constructs the Armatron robot-arm program during compilation. - 7. Use const fn to build values of different types, then use &dyn Trait to combine them without heap allocation.
Example: One audio sequence combines an uncompressed tone, silence, and compressed NASA speech. - 8. Give up. When parsing with const fn becomes too complex or too slow, use a procedural macro, build script, or command-line generator.
Example: A motion-captured pirouette splits the work between const fn and a command-line generator. - 9. const fn does not choose between const and static; use static when address identity matters.
Example: Armatron’s touchscreen sliders use their static addresses as IDs.
In the conclusion, in Part 2, we’ll compare const fn with C++, Zig, and alternative Rust approaches. We’ll argue that it uniquely combines strong constraints against unsafe or invalid states with build-time computation that remains in the same program as its result.
The first rule shows the central technique in a small example: include a file, process its bytes with const fn, and retain only the result.
Rule 1: Process external files in const fn: no build.rs, no proc macros, no runtime processing.
Rust’s built-in include_bytes!() can feed a file directly to a const fn. The compiler processes the bytes during const evaluation and retains only the result your program needs.
Consider this example (Rust Playground):
// The Playground provides Cargo.toml as a convenient input.
// A real program would usually include a data file instead.
// This could also be a `static`; we'll discuss that choice later.
const SUM: (u128, Option) = sum_u16s(include_bytes!("../Cargo.toml"));
fn main() {
println!("Compile-time sum: {}", SUM.0);
println!("Extra byte: {:?}", SUM.1);
}
const fn sum_u16s(data: &[u8]) -> (u128, Option) {
let (value_bytes, remainder) = data.as_chunks::<2>();
let mut value_index = 0;
let mut sum = 0_u128;
while value_index < value_bytes.len() {
sum += u16::from_le_bytes(value_bytes[value_index]) as u128;
value_index += 1;
}
(sum, if remainder.is_empty() { None } else { Some(remainder[0]) })
}
For me, this produced:
Compile-time sum: 916560423
Extra byte: Some(10)
You may see something different if the Playground’s generated Cargo.toml changes.
What’s happening
- include_bytes!() reads the file at compile time.
- The loop runs entirely in const evaluation.
- The compiler computes SUM during compilation.
- The compiler stores only the u128 and Option value in the final binary. Rust does not store the included file at all.
Why this is interesting
The example is intentionally simple: an external file can participate directly in const evaluation. Standard macro include_bytes!() supplies the bytes, a const fn processes them, and the program keeps only the resulting value.
The input file does not have to remain in the binary. In this example, the file may contain hundreds of bytes, but the retained value is only a u128 and an Option. The binary grows with the representation you retain, not with the size of the input file.
Rule 2 applies the same pattern to a real file format.
Aside: A build script (build.rs) lets you process files more powerfully but less ergonomically. We’ll discuss this alternative in Rule 8.
Limitations
- Your processing functions must be const fn, which restricts the operations you can use. For example, Rust does not currently support for loops in constant functions, so you may need while. Rust continues to expand what it permits in const fn, however, so these restrictions are gradually easing.
- The compiler does not strictly guarantee that it will omit the included file from the final binary, but in practice, it consistently appears to optimize it away when you use the data only during constant evaluation.
Rule 2: Process simple file formats at compile time. Validate the input, transform its contents, and retain only the result.
The pattern from Rule 1 becomes useful when a const fn can interpret the included bytes as a real file format. It can validate the file, transform its contents into exactly the representation the program needs, and stop the build when an asset is invalid.
Consider Skeleton Clock, an application built with Device Envoy’s new support for the Cheap Yellow Display (CYD).
The CYD is an inexpensive ESP32 development board with a built-in 240×320 display and touchscreen.

Device Envoy’s CYD support is not limited to the original ESP32-based board. The same display and touchscreen can be used with other ESP32-family chips or Raspberry Pi Picos, and Device Envoy also supports WebAssembly and an in-memory backend for testing. You can therefore design, test, and debug an application in the browser, then run the shared code on a microcontroller.
Skeleton Clock uses the new Linkage Blaze crate to describe and animate its three-dimensional skeleton. Linkage Blaze lets applications design and simulate mechanisms such as robot arms and clock hands. Rules 6 and 8 explore those capabilities.
For now, let’s put the skeleton in the closet and consider only the clockface background image:

The application imports the background image from a TGA file, a simple bitmap image format, with one const expression:
/// Clockface background bitmap, loaded at compile time.
const BACKGROUND_BITMAP: Image565Fixed<240, 320, { 240 * 320 }> =
tga!("../assets/clock_back.small.tga").to_565();
This small tga! macro wraps the pattern from Rule 1. The macro expands to:
Image888Fixed::from_tga(include_bytes!(
"../assets/clock_back.small.tga"
))
.to_565()
This expression:
- includes the TGA file during compilation;
- validates its header, dimensions, and pixel data;
- decodes its BGR or BGRA pixels (blue, green, red, with optional alpha) into RGB888 (8 bits each of red, green, and blue);
- converts those pixels into the display’s RGB565 format; and
- leaves only the final RGB565 image for the firmware.
This requires neither a build script nor runtime file access. include_bytes! supplies the bytes, and const evaluation runs our parser during compilation.
Give the data a shape
The destination type states the width, height, and pixel count:
pub struct Image888Fixed {
pub pixels: [[u8; 3]; N],
}
pub struct Image565Fixed {
pub pixels: [u16; N],
}Image888Fixed stores three bytes per pixel, one each for red, green, and blue. Image565Fixed stores each pixel as one packed u16.
The separate N is logically redundant because it must equal W * H. Stable Rust, however, does not yet permit [u16; W * H] as a field of this const-generic type. The constructor therefore asserts N == W * H; a mismatch fails during const evaluation.
Prefer a simple input format
Why TGA instead of the more familiar JPEG or PNG? An uncompressed true-color TGA has a short header followed by BGR or BGRA pixels. A small const fn can decode a useful subset of the format. JPEG and PNG, in contrast, require much more involved decoders.
Here is the pixel-reading part of Image888Fixed::from_tga:
impl Image888Fixed {
/// Decodes a supported TGA at compile time, preserving RGB and discarding alpha.
pub const fn from_tga(bytes: &[u8]) -> Self {
assert!(N == W * H, "Image888Fixed: N must equal W * H");
let (pixel_start, bytes_per_pixel, top_origin) =
parse_header(bytes, W, H);
let mut pixels = [[0u8; 3]; N];
let mut y = 0;
while y < H {
let mut x = 0;
while x < W {
let source_y = if top_origin { y } else { H - 1 - y };
let offset =
pixel_start + (source_y * W + x) * bytes_per_pixel;
let red = bytes[offset + 2];
let green = bytes[offset + 1];
let blue = bytes[offset];
pixels[y * W + x] = [red, green, blue];
x += 1;
}
y += 1;
}
Self { pixels }
}
// ...
}The omitted parse_header is a short helper that checks at compile time:
- that the file contains a complete header and pixel payload;
- that it is an uncompressed true-color TGA with no color map;
- that it uses a supported pixel depth and direction; and
- that its width and height match the Rust declaration.
If any check fails, compilation fails.
Retain the representation the device wants
The CYD display expects RGB565 pixels, with 5 bits of red, 6 bits of green, and 5 bits of blue. A second const fn converts the imported RGB888 image into that representation:
/// Converts this source image to RGB565.
pub const fn to_565(&self) -> Image565Fixed {
let mut pixels = [0u16; N];
let mut index = 0;
while index < N {
let [red, green, blue] = self.pixels[index];
pixels[index] = ((red as u16 >> 3) << 11)
| ((green as u16 >> 2) << 5)
| (blue as u16 >> 3);
index += 1;
}
Image565Fixed { pixels }
}
The original 240 × 320 BGRA TGA file occupies 307,244 bytes. The final RGB565 image occupies 153,600 bytes. Because the program uses only the result of to_565, neither the original file nor the intermediate RGB888 pixels occupy space in the firmware.
Aside: An Image565Fixed can also provide cropped views of type Image565View without copying pixels. The fixed image owns the pixels; each view borrows a rectangular region. See Device Envoy’s CYD documentation for ESP32 and Raspberry Pi Pico for more about Device Envoy’s CYD module, including touchscreen input, tiled rendering, and pixel streaming.
Rule 2 conclusion
Pair include_bytes! with a const fn when the input format is simple enough to parse cleanly. For Skeleton Clock, compilation validates the TGA and converts it into an exactly sized RGB565 image, leaving the firmware with only the representation it needs.
Rule 3: Make two compile-time passes with a declarative macro. First derive sizing and other constants, then use them to construct an exactly sized value.
Consider Device Envoy’s audio_player module (ESP, RP). It plays sequences of audio clips over common I²S hardware and provides runtime sequencing and volume control. Const evaluation imports and validates the clips before the program runs.
As in Rule 2, the code to play audio starts by loading a simple file at compile time. This time, however, we let the file determine the size of the value we construct.
The same technique could derive the dimensions of a TGA image, but image dimensions are usually known in advance. An audio clip, in contrast, can contain tens of thousands of samples, so keeping track of the exact sample count ourselves would be tedious and fragile.
Fortunately, the compiler can inspect the input file first. Depending on the format, that inspection can discover its byte length, dimensions, record count, sample rate, channel count, or other useful facts. Once those facts are available as constants, a second compile-time pass can read or transform the contents into an exactly sized result.
This leaves one annoyance: writing both passes requires naming the file path twice. Repeating the path is redundant, and a mistake could make the first pass derive constants from one file while the second reads another.
A declarative macro can generate both passes from a single file path. Here is how we include an audio file with Device Envoy’s declarative pcm_clip! macro:
pcm_clip! {
Nasa {
file: "../../../device-envoy-examples-rp/examples/data/audio/nasa_22k.s16",
source_sample_rate_hz: 22_050,
}
}
use Nasa::{PCM_SAMPLE_COUNT, SAMPLE_RATE_HZ};
// ...
async fn inner_main(spawner: Spawner) -> Result {
const NASA: PcmClipBuf
= Nasa::pcm_clip();
//...Let’s look at the details.
The input format
The .s16 file extension here means raw, signed 16-bit, mono PCM—a headerless, uncompressed audio format that most audio tools can create. Each sample represents the sound wave's instantaneous air-pressure deviation as an integer from −32,768 to 32,767. Larger swings away from zero generally correspond to louder sound.
The sample rate tells the number of samples present in one second of audio. I created this file with a sample rate of 22,050 samples per second. Because .s16 has no header, the file does not record that rate, so we supply it as source_sample_rate_hz: 22_050.
First stage: discover and validate the size
Although the file does not contain its sample rate, include_bytes! makes its byte length available during compilation. Each signed 16-bit sample occupies two bytes, so pcm_clip! can derive the exact sample count by dividing by two.
Here is the essential work that pcm_clip! performs behind the scenes:
const SAMPLE_RATE_HZ: u32 = 22_050;
const NASA_BYTES: &[u8] =
include_bytes!("../../../device-envoy-examples-rp/examples/data/audio/nasa_22k.s16");
const _: () = assert!(
NASA_BYTES.len() % 2 == 0,
"s16le requires exactly two bytes per sample"
);
const NASA_SAMPLE_COUNT: usize = NASA_BYTES.len() / 2;
The first stage measures and validates the input. NASA_SAMPLE_COUNT then becomes a const-generic argument in the exact output type.
Second pass: construct the value
The second stage uses const fn read_s16le to walk through the bytes two at a time, converts each little-endian pair into an i16, and fills that exactly sized result.
const NASA: PcmClipBuf =
read_s16le::(NASA_BYTES);
Device Envoy wraps this array of sample values in PcmClipBuf. Its length comes from the input rather than from a number supplied by the programmer. No counting or parsing happens at runtime.
Rule 3 conclusion
When information in an input affects the result’s type or construction derive that information first and expose it as constants. A second compile-time pass can then use those constants to construct an exactly sized or otherwise specialized value. A declarative macro can tie both passes to one input, avoiding duplicated paths and keeping the derived constants consistent with the data they describe.
Rule 4: Use const fn to compose large values from small building blocks instead of listing every element.
Rules 1, 2, and 3 imported files. This example involves no files. Instead, we’ll use Device Envoy to compose an LED-panel layout from const fn calls and the values they produce.
In this video, Device Envoy uses the resulting layout to render “Go Go”:
Applications draw on an LED panel using (x, y) coordinates, but the hardware presents one long chain of lights wired in a serpentine path. We stack two 12×4 panels to create a 12×8 layout, then rotate it to match the physical 8×12 display:

With const fn, we can conveniently specify a panel’s layout at compilation time:
// Two 12x4 panels stacked vertically to create a 12x8 display.
const LED_LAYOUT_12X4: LedLayout<48, 12, 4> =
LedLayout::serpentine_column_major();
const LED_LAYOUT_12X8: LedLayout<96, 12, 8> =
LED_LAYOUT_12X4.combine_v(LED_LAYOUT_12X4);
const LED_LAYOUT_12X8_ROTATED: LedLayout<96, 8, 12> =
LED_LAYOUT_12X8.rotate_cw();
The code starts with a 12×4 layout, stacks two copies into a 12×8 layout, and rotates the result into the final 8×12 layout. These operations produce the final mapping between physical chain indices and (x, y) coordinates. The intermediate layouts do not remain in the firmware; only the final 8×12 layout does.
Next, let’s see how the type, its constructor, transformations, and compile-time tests work together to ensure that the generated layout is valid.
Give the layout a checked type
The LedLayout struct carries its LED count and dimensions as part of its type. It stores the layout in both directions:
pub struct LedLayout {
index_to_xy: [(u16, u16); N],
xy_to_index: [u16; N],
}The two arrays answer opposite questions. index_to_xy[led_index] tells where a particular physical LED appears in the layout. xy_to_index[y * W + x] tells the drawing code which physical LED corresponds to a requested (x, y) coordinate. Storing both directions makes either lookup a single array access.
Every constructor, including LedLayout::serpentine_column_major(), ultimately calls LedLayout::new, which we’ll examine next.
pub const fn new(index_to_xy: [(u16, u16); N]) -> Self {
assert!(W > 0 && H > 0, "W and H must be positive");
assert!(W * H == N, "W * H must equal N");
assert!(N <= u16::MAX as usize, "total LEDs must fit in u16");
let mut seen = [false; N];
let mut xy_to_index = [0_u16; N];
let mut led_index = 0;
while led_index < N {
let (x, y) = index_to_xy[led_index];
let x = x as usize;
let y = y as usize;
assert!(x < W, "x coordinate out of bounds");
assert!(y < H, "y coordinate out of bounds");
let cell_index = y * W + x;
assert!(!seen[cell_index], "duplicate coordinate");
seen[cell_index] = true;
xy_to_index[cell_index] = led_index as u16;
led_index += 1;
}
let mut cell_index = 0;
while cell_index < N {
assert!(seen[cell_index], "mapping does not cover every cell");
cell_index += 1;
}
Self {
index_to_xy,
xy_to_index,
}
}The new method receives the physical-index-to-coordinate mapping, validates it, and constructs the coordinate-to-physical-index mapping. The bounds checks reject coordinates outside the panel. The duplicate and coverage checks ensure that every coordinate corresponds to exactly one physical LED. In a constant definition, the compiler performs this work during compilation, leaving a complete, validated, bidirectional layout for runtime code.
Let transformations change the type
Each operation expresses one physical fact: combine_v places one layout below another; rotate_cw rotates a layout clockwise.
Rotation also shows how const generics can describe more than array lengths. Rotating a W-by-H layout returns an H-by-W layout:
pub const fn rotate_cw(self) -> LedLayout {
let mut index_to_xy = [(0_u16, 0_u16); N];
let mut index = 0;
while index < N {
let (x, y) = self.index_to_xy[index];
index_to_xy[index] = ((H - 1 - y as usize) as u16, x);
index += 1;
}
LedLayout::::new(index_to_xy)
}The return type swaps W and H. Declaring the rotated constant as LedLayout<96, 12, 8> instead of LedLayout<96, 8, 12> therefore fails to compile, catching the geometry error where we define the layout.
LedLayout supports horizontal and vertical composition; clockwise, counterclockwise, and 180-degree rotation; and horizontal and vertical reflection. Together, these small operations describe larger physical arrangements without listing every coordinate.
Not every wiring pattern fits these building blocks. For an unusual panel, users can call LedLayout::new directly with a full coordinate table.
Test the generated table during compilation
We can even test a layout at compilation time. It is like testing without running tests.
const ROTATED: LedLayout<6, 2, 3> =
LedLayout::serpentine_column_major().rotate_cw();
const EXPECTED: LedLayout<6, 2, 3> = LedLayout::new([
(1, 0), (0, 0),
(0, 1), (1, 1),
(1, 2), (0, 2),
]);
const _: () = assert!(ROTATED.equals(&EXPECTED));
const _: () forces the compiler to evaluate the assertion without introducing a name. LedLayout::equals exists because the derived PartialEq implementation is not const-callable on stable Rust. This is a case where a small const-friendly helper fills a gap.
Rule 4 conclusion
The const expressions describe two panels and a rotation rather than listing 96 coordinate pairs. During compilation, const fn expands those building blocks into a complete, validated, bidirectional layout. At runtime, drawing code can map an (x, y) coordinate directly to the corresponding physical LED.
The same principle appears in servo motor animations (ESP, RP), where const fn combines smaller generated sequences into larger fixed-size sequences. In both cases, compact rules compose a large value without listing every element.
So, there you have it: the first four rules for doing work at compile-time with const fn. Part 2 will cover rules 5 to 9 and include examples with simulated robot arms, motion-capture data, and sequenced audio.
Aside: If you’re interested in future articles, please follow me on Medium. I write on scientific programming in Rust and Python, machine learning, and statistics. I tend to write about one article per month.