Skip to content
academia.sh

Lesson 07 / 12

Embedded Targets

How a target where the operating system services underlying the standard library (printing, memory allocation, panic handling) do not exist at all is made visible in the source; that two bodies doing the same job, one by allocating and one with a fixed-size buffer, give the same result is shown by running code.

Published Updated

In this lesson

  • Shows that the printing service depends on the operating system, and how conditional compilation marks this dependency in the source
  • Proves by comparison that a body that allocates and a body that works with a fixed-size buffer produce the same result
  • Explains with an example why a fixed-size buffer falls short on an input that exceeds its capacity limit
Contents

The previous lesson measured the same operating system offering different services on two separate targets: the file system and threads either did not exist at all on the browser target, or arrived through a different route. This lesson carries the boundary to an extreme — a target where the operating system does not exist at all. On such a target, it is not just a few services but the very foundation the standard library rests on that changes: printing, memory allocation, and panic handling — all three assume an operating system exists.

A target of this kind typically represents hardware with limited memory that does not run an operating system of its own: a sensor board, a motor controller, a card payment device. On hardware like this, code runs directly on the processor; underneath it there is no layer that manages a file system, organizes memory allocation, or prints a message to a terminal when a program crashes. For this environment, Rust does not offer the entire standard library, only a narrower library that supplies the core language and hardware-independent basic types (integers, slices, option and result types); parts that depend on the operating system, such as files, threads, networking, and memory allocation, are left out of this narrow library. This distinction is marked in the source with a declaration: this lesson does not use that declaration, because source that uses it cannot be run as an ordinary program on this machine — the declaration says that no call remaining requests the whole standard library, otherwise compilation stops. The distinction measured in this lesson can be read from the source even without that declaration being written: which body relies on which service is evident from the call it uses.

In this lesson too, no real compilation is done for an embedded target. The target-dependent decision, as in the previous lesson, is modelled through the source: cfg determines which of the two bodies in the source is selected, and only one enters compilation.

Printing Being an Operating System Service

A print call runs an operating system call behind it: it writes text to a stream, and the stream is directed by the operating system to a terminal or a file. On a target where the first link in this chain — the operating system itself — is absent, the same call cannot write anywhere. In the previous lesson, the absence of a service (the file system) came back as an error value — the program still compiled, and the absence was understood at run time through a Result. The print call’s situation here is different: in the narrow edition of the standard library, this call itself does not exist at all, so its absence is not a run-time result but a compile-time one — source that contains the call never compiles at all for that target. The run below marks this dependency with cfg.

LL15. The printed message is "measurement complete" — reporting that a stage assumed to process the previous lesson’s sensor record has finished.

// embedded1.rs — printing being dependent on the operating system
#[cfg(not(target_os = "none"))]
fn print_info(message: &str) {
    // Writing to the standard output stream: a service the operating system provides.
    println!("{}", message);
}

#[cfg(target_os = "none")]
fn print_info(message: &str) {
    body_writes_a_different_way_on_the_embedded_target(message)
}

fn main() {
    print_info("measurement complete");
}
measurement complete

target_os = "none" is the value carried by targets without an operating system; because this machine runs under an operating system, it does not satisfy this condition, and the body marked not(target_os = "none") enters compilation. The opposite body, as in the previous lesson’s wasm example, sits in the source without ever being compiled — the undefined call inside it therefore causes no problem at all. LL16. On a target that genuinely has no operating system, for this second body to do the printing job it needs an entirely different method, such as writing directly to a hardware register or sending data to a debug interface — this method is independent of the stream abstraction the operating system provides.

target_os = "none" asks a different question from the target_family = "wasm" used in the previous lesson. target_family asks whether a target belongs to a broad environment class (the wasm family, the unix family); target_os, by contrast, asks a narrower question and names which operating system the target runs on, or whether it runs on none at all. The value "none" is the single case where the answer to this question is “none” — underneath the target there is no file system, process management, or stream-providing layer. The same attribute can also carry other values (such as an operating system’s name), but this lesson concerns itself only with the “no operating system at all” case.

The difference between these two bodies is built in the same way as the file system example in the previous lesson, but the service it rests on differs. There, what was missing was a single call; here, what is missing is the stream abstraction itself that underlies every print call in the standard library. A print call actually sits at the top of a chain of layers: it formats the text, hands the result off to a function that writes to the standard output stream, and that function in turn uses a write call the operating system provides underneath. If the last link in this chain is missing, the rest of the chain becomes meaningless too — which is why this print path does not appear directly in the narrow library prepared for a target without an operating system.

The Same Job, Allocating and Not Allocating

Memory allocation carries a similar dependency too, but it is clearer to show the difference not with cfg but with two different forms: both forms compile and run on this machine, the difference between them is only that one allocates memory and the other does not. In the print example, only one of the two bodies was compiled; here, both are compiled and both run, because what is measured is not which one is compiled but which source each relies on to produce the same result. The run below collects the same sensor record in two separate ways.

// embedded2.rs — an allocating body and a non-allocating body giving the same result
fn sum_by_allocating(records: &[i32]) -> i32 {
    let mut accumulator: Vec<i32> = Vec::new();
    for &value in records {
        accumulator.push(value);
    }
    accumulator.iter().sum()
}

fn sum_with_buffer(records: &[i32]) -> i32 {
    const CAPACITY: usize = 8;
    let mut buffer = [0i32; CAPACITY];
    let mut filled = 0usize;
    for &value in records {
        buffer[filled] = value;
        filled += 1;
    }
    buffer[..filled].iter().sum()
}

fn main() {
    let records = [12, 7, 5, 21];
    println!("allocating total: {}", sum_by_allocating(&records));
    println!("buffer total: {}", sum_with_buffer(&records));
}
allocating total: 45
buffer total: 45

LL17. Both totals are 45 — the same four numbers, the same result. sum_by_allocating widens the growable array’s capacity as needed on every push call; this widening results in requesting a new block from a memory allocator, and the allocator is part of the runtime. sum_with_buffer, by contrast, touches no allocator at all: the buffer array’s size is predetermined in the source with the CAPACITY constant, the array is as short-lived as a single function call, and it sits in a region whose place in memory is known at compile time.

On a target without a memory allocator, sum_by_allocating either does not compile, or, even if it does, it cannot proceed at run time because the allocation request cannot be satisfied; sum_with_buffer, by contrast, produces the same result even on such a target, because it needs no external source at all. This difference does not show up in the run — both worked without issue on this machine — it shows up only in the choice, in the source, between Vec and a fixed-size array.

This choice is not limited only to whether an allocator exists. On hardware with little memory, such as a sensor board, knowing in advance the most memory a program will ever use carries a separate value: because buffer’s size is fixed in the source, the amount of memory this function will use is known at compile time and depends on no run-time decision at all. The memory used in the Vec-based form changes with the number of incoming records; if the record count is not known in advance, the upper bound to be used is not known in advance either. The constraint a fixed-size buffer brings — that the upper bound is written in the source — is also the source of this predictability.

The Limiting Measurement

There is one condition for the fixed-size buffer to do this job: CAPACITY must be greater than or equal to the number of incoming records. In the run above, four records fit into a buffer with a capacity of eight. If the record count exceeded the capacity and the code did not check for this, the line buffer[filled] = value would try to write past the array’s bounds — because bounds checking always runs in Rust, this does not pass silently, the program would stop at run time. Because this lesson does not trigger a panic, it does not run that situation; instead, it runs a form that handles the same boundary without panicking.

LL18. Ten records are sent, capacity is eight; the first eight are accepted and summed, the last two are rejected.

// embedded3.rs — a buffer that rejects input past capacity without panicking
fn sum_with_buffer_safe(records: &[i32]) -> (i32, usize) {
    const CAPACITY: usize = 8;
    let mut buffer = [0i32; CAPACITY];
    let mut filled = 0usize;
    let mut rejected = 0usize;
    for &value in records {
        if filled < CAPACITY {
            buffer[filled] = value;
            filled += 1;
        } else {
            rejected += 1;
        }
    }
    (buffer[..filled].iter().sum(), rejected)
}

fn main() {
    let many_records = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    let (total, rejected) = sum_with_buffer_safe(&many_records);
    println!("accepted total: {} rejected records: {}", total, rejected);
}
accepted total: 36 rejected records: 2

This form never crosses the boundary, by combining the filled < CAPACITY check with the write in the same operation — the ninth and tenth records are counted and set aside without ever being written to the buffer at all. The result is information instead of a panic: how many records were accepted, how many were rejected. LL19. This shows a common habit on an embedded target: because there is no operating system to catch a crash and print the message, checking the boundary by hand in advance and reporting the state with a result value is accepted as a more reliable form than stopping with a panic — even though post-panic behavior changes by target, a result value can be handled the same way on every target.

sum_by_allocating carries no such boundary — Vec grows itself when its capacity fills, and it works even when the record count is not known in advance. This shows the real trade-off between the two forms: the fixed-size buffer does not require the target to provide an allocator, but its upper bound has to be written in the source, and when that bound is exceeded, it either stops or (if written by hand as above) rejects the excess. The growable array, by contrast, carries no upper bound, but it requires an allocator to exist. A non-allocating form is not always possible; in a job where the record count is learned at run time and cannot be bounded in advance, a fixed-size buffer has to either reject the excess or stop.

In a real embedded system, CAPACITY is usually not chosen arbitrarily; it is tied to a number that comes from the hardware itself — how many sensor channels a board has, how many bytes a communication frame carries at most, the size of the memory region a buffer fits into. The 8 chosen for this lesson represents such a hardware constraint; the ten-record input was chosen to show a situation where that constraint is exceeded. Instead of silently discarding the input that exceeds it, the code reports it by counting how many were rejected — another habit commonly seen in embedded software: passing a boundary without noticing it is a worse outcome than noticing the boundary was passed and recording that fact somewhere.

Panic behavior itself is also part of this boundary. Under an operating system, a panic prints a message and stops the program; in some configurations the program also tries to unwind from that point, in others it stops directly — but in both cases this behavior rests on the process management an operating system provides. The message is written to a stream, the process returns to the operating system with an exit code, and the operating system closes that process. On a target without an operating system, none of the links in this chain exist; the narrow edition of the standard library does not specify which behavior is followed at the moment of a panic, it only requires that this behavior be defined separately by the source. The writer can choose, in this definition, to write a fixed value to a hardware register, to put the processor into an infinite loop in a low-power mode, or to send a signal to a debug interface; which one is chosen depends on the hardware itself and which recovery routes that hardware offers. This lesson does not run such a definition, it only mentions its existence — because it is a dependency belonging to the same class as printing and allocation.

What the three dependencies (printing, allocation, panic handling) share is that none of them is missing from the language itself. Rust recognizes all three at the language level; the print syntax, the Vec type, the panic keyword — all of them are part of the core language or the standard library. What is missing is the environment these three need in order to work — a stream, an allocator, process management. The language stays the same, the environment the target provides changes; that is why the class of these three dependencies is outside the language, not a deficiency of the compiler or the standard library.

Summary

  • Printing, memory allocation, and panic handling are the standard library’s three basic services that depend on the operating system; on a target without an operating system (target_os = "none"), all three have to be handled separately in the source
  • No real compilation was done for an embedded target; the target-dependent decision was modelled through the source with cfg
  • The allocating body (Vec) and the body working with a fixed-size buffer gave the same total (45); the difference is not in the result but in one of them requiring an allocator
  • A target not offering a memory allocator, an operating system service, or a panic handler shows that the guarantee this target gives comes not from the language itself but from the target — the class is outside the language
  • A fixed-size buffer requires no allocator, but its upper bound has to be written in the source; when this bound is exceeded, the non-allocating form either stops or (if the bound was checked by hand) rejects the excess without panicking

Next Step

Throughout this topic, the guarantee always ended in the same place: not the compiler’s checking, but a mechanism outside the language was what determined it. In the unsafe Rust lesson, that mechanism was a contract delegated to the writer; in the Foreign Function Interface lesson, it was the signature of a language the compiler never sees; in the WebAssembly Target lesson, it was an environment isolated by the browser; in this lesson, it was the absence of the operating system itself. What all four share is that the boundary of the guarantee is readable from the source — the unsafe block, the extern declaration, and the cfg label are all three marks that flag this boundary in the source; none of them is a hidden behavior that has to be discovered during a run. The next topic departs from this axis: what it asks there is which guarantee a dependency or a test gives, not what a target takes away.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close