TRK-014 · RUST / CALCULATORS

Learning Rust by building an RPN calculator firmware

DATE2026-06-21
READ~5 min
REV2026-07-03
TAGSrust, calculators

Every Rust tutorial wants me to build a web server. I don't want a web server — I have never once, standing at the workbench with a resistor in one hand, wished for more middleware. I want a calculator. Specifically an RPN calculator with a real keypad, because the stack model maps almost one-to-one onto Rust's ownership rules: values are moved off the stack, consumed by an operation, and a result is pushed back. The borrow checker and RPN were separated at birth; it just took fifty years for them to meet.

This first part covers the core evaluator. Part 2 does the keypad, Part 3 the display — and a reckoning with floating point that I'll foreshadow at the end.

A short defense of typing backwards

RPN — 3 4 + instead of 3 + 4 — reads like a stroke until you learn where it came from. In the 1920s the Polish logician Jan Łukasiewicz worked out that if you write the operator before its operands, parentheses become unnecessary; the notation carries the structure. In 1957 Charles Hamblin, an Australian philosopher moonlighting as a computer scientist, flipped it around: operator after operands, evaluated with a push-down stack, and suddenly a machine could compute expressions with almost no parsing and almost no memory. Both were rounding errors on the hardware budgets of the day, so the idea stuck.

Then in 1972 Hewlett-Packard shipped the HP-35 — the first scientific pocket calculator, built RPN-style around a four-level stack, allegedly because Bill Hewlett wanted something that fit in his shirt pocket. It cost $395, roughly $3,000 in today's money, and engineers paid it, because the alternative was a slide rule. Within four years, Keuffel & Esser — slide rule makers since the 19th century — shipped their last one. RPN didn't just win an argument about notation; it ended an entire industry of bamboo.

The deeper point for our purposes: RPN needs no parser, no precedence table, no parentheses — just a stack. On a microcontroller, "the whole language is a Vec" is a very good deal.

The evaluator

Pop two operands, apply the operator, push the result. My first version returned Option<f64> and bailed with ? on stack underflow, which was pleasingly terse. Then I noticed it had a design flaw my HP-32S would never tolerate: on error it had already eaten one of your operands. You typed 5 +, got an error, and your 5 was gone. A calculator that punishes typos by confiscating your numbers is a calculator you throw at the wall.

So the real version restores the stack before reporting failure:

#[derive(Clone, Copy, Debug)]
enum Op { Add, Sub, Mul, Div }

#[derive(Debug, PartialEq)]
enum Error { Underflow, DivByZero, BadToken }

fn eval(stack: &mut Vec<f64>, op: Op) -> Result<f64, Error> {
    let b = stack.pop().ok_or(Error::Underflow)?;
    let Some(a) = stack.pop() else {
        stack.push(b);              // put it back — HP would never eat your operand
        return Err(Error::Underflow);
    };
    let r = match op {
        Op::Add => a + b,
        Op::Sub => a - b,
        Op::Mul => a * b,
        Op::Div if b == 0.0 => {
            stack.push(a);          // restore both before bailing
            stack.push(b);
            return Err(Error::DivByZero);
        }
        Op::Div => a / b,
    };
    stack.push(r);
    Ok(r)
}

This tiny function is a surprisingly complete tour of Rust's error-handling toolbox: ok_or converts the Option from pop() into a Result, ? propagates the easy case, let–else handles the case that needs cleanup before returning, and a match guard (Op::Div if b == 0.0) splits division into its polite and impolite variants.

The ownership story is the part the tutorials promise and RPN actually delivers: pop() moves each f64 out of the stack. The operands stop existing anywhere else; they're consumed by the match arm; one new value goes back. No aliasing, no "who owns this now" — the stack discipline and the ownership discipline are the same discipline.

NOTE — Without the explicit check, 1 0 / yields inf, not a panic: IEEE 754 does its own error handling and floats along happily. That's correct for numerics and wrong for a calculator, where dividing by zero should produce a shamefaced Error on the display, as tradition demands.

Driving it from a string

The keypad doesn't exist yet (Part 2), so for now the "keyboard" is a string and the whole language is split_whitespace:

fn run(line: &str) -> Result<f64, Error> {
    let mut stack = Vec::new();
    for tok in line.split_whitespace() {
        match tok {
            "+" => { eval(&mut stack, Op::Add)?; }
            "-" => { eval(&mut stack, Op::Sub)?; }
            "*" => { eval(&mut stack, Op::Mul)?; }
            "/" => { eval(&mut stack, Op::Div)?; }
            n => stack.push(n.parse().map_err(|_| Error::BadToken)?),
        }
    }
    stack.pop().ok_or(Error::Underflow)
}

And because claims on the internet are worthless without tests, these all pass:

assert_eq!(run("3 4 + 2 *"), Ok(14.0));
assert_eq!(run("12 3 /"), Ok(4.0));
assert_eq!(run("1 0 /"), Err(Error::DivByZero));
assert_eq!(run("5 +"), Err(Error::Underflow));
assert_eq!(run("2 potato +"), Err(Error::BadToken));

let mut s = vec![7.0];
assert_eq!(eval(&mut s, Op::Add), Err(Error::Underflow));
assert_eq!(s, vec![7.0]);   // the operand survived the error

Note the last one: errors are now transactional. The stack after a failed operation is the stack before it. This took four extra lines and is the difference between a calculator and a slot machine.

The hardware, and a confession about floats

The target is an STM32F103 "Blue Pill" board with a salvaged membrane keypad and a 128×64 SSD1306 OLED. Total parts cost: about two coffees, one of them a fancy one.

Here's the confession. The STM32F103 is a Cortex-M3, and a Cortex-M3 has no floating-point unit. None. Every f64 operation in the code above compiles into a call to a software routine that does the arithmetic limb by limb, in hundreds of cycles, like a Victorian clerk. The elegant a + b in my match arm is a polite fiction maintained by the compiler.

Does it matter? For a calculator: not even slightly. The chip runs at 72 MHz and a human being tops out at maybe five keystrokes a second — even a soft-float divide leaves us roughly 99.99% idle. The Apollo Guidance Computer went to the Moon with less. But it offends me, and worse, binary floating point is the wrong tool for a device whose entire job is decimal arithmetic in front of a skeptical human: the classic HPs did decimal (BCD) math internally precisely so the display never lied.

So that's Part 3's fight: decimal arithmetic, number formatting, and why 0.1 + 0.2 is the kind of thing that gets you angry emails. Part 2 first, though — the keypad, a device whose electrical behavior can charitably be described as "creative".