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.