Skip to main content

Hacking on NumCore

A practical guide for working on the firmware day-to-day.

Build commands

# Build firmware (release, size-optimised)
make build
# or
cargo build -p numcore-lm3s811 --release --target thumbv7m-none-eabi

# Build firmware (debug — faster compile, larger Flash)
cargo build -p numcore-lm3s811 --target thumbv7m-none-eabi

# Run host-side unit tests (300 tests)
make test
# or
cargo test -p numcore_math --tests

# Build + test
make all

# Check compilation only
cargo check -p numcore-lm3s811 --release --target thumbv7m-none-eabi

# Clean
cargo clean

# Build WASM browser simulator
make wasm
# or
cargo build -p hal-wasm --target wasm32-unknown-unknown

# Build + generate JS bindings + serve locally
make wasm-serve
# Open http://localhost:8080

# Build standalone offline HTML file
make wasm-standalone
# target/wasm32-unknown-unknown/debug/index-standalone.html

Release profile

[profile.release]
opt-level = "z" # minimise code size
lto = true # fat LTO across all crates
codegen-units = 1 # single CGU for maximum optimisation
panic = "abort" # no unwind tables
strip = "symbols" # remove ELF symbol table

Debug builds omit all optimisation — use only for QEMU testing (debug binaries exceed 64 KB Flash).

No default target

.cargo/config.toml does not set a default build target, allowing cargo test for the host-side test-suite to work without --target. Always use --target thumbv7m-none-eabi for firmware builds.

Running in QEMU

Development (debug, fast iteration)

cargo build -p numcore-lm3s811 --target thumbv7m-none-eabi && \
qemu-system-arm -M lm3s811evb -serial mon:stdio -display none \
-kernel target/thumbv7m-none-eabi/debug/NumCore

Release testing

make build && \
qemu-system-arm -M lm3s811evb -serial mon:stdio -display none \
-kernel target/thumbv7m-none-eabi/release/NumCore

With simulated OLED display

cargo build -p numcore-lm3s811 --target thumbv7m-none-eabi && \
qemu-system-arm -M lm3s811evb -serial mon:stdio -display gtk \
-kernel target/thumbv7m-none-eabi/debug/NumCore

Pipe expression via stdin

echo "2+2" | timeout 5 qemu-system-arm -M lm3s811evb -serial stdio -display none \
-kernel target/thumbv7m-none-eabi/release/NumCore

Quick expression tests

> sin(pi/2) → = 1
> cos(0) → = 1
> sqrt(-1) → ! error (Standard mode)
→ = i (Advanced mode, Esc to toggle)
> int(sin(X),X,0,pi) → = 2
> sum(X,X,1,10) → = 55
> sto(42,A) → = 42
> A → = 42
> [(1,2)(3,4)] → = [1 2; 3 4] (Matrix mode)
> det([(1,0)(0,1)]) → = 1
> !mode Scientific → (switch to Scientific mode)
> 1.5E+10 → = 1.5E+10
> 9E99*9E-99 → = 81

Host-side unit tests

The test-suite (test-suite/) includes every numcore/src/math/*.rs file via #[path] attributes. 300 tests cover the entire math engine.

Running tests

# All tests
cargo test -p numcore_math --tests

# Single test
cargo test -p numcore_math --tests test_sqrt_perfect_squares

# Run with output
cargo test -p numcore_math --tests -- --nocapture

Ignored tests

6 tests are ignored on host due to differences between the embedded target (Cortex-M3, saturating arithmetic) and the host (x86_64, wrapping arithmetic). All pass correctly on the embedded target.

Firmware metrics

Flash budget

SectionSize (bytes)% of Flash
.vector_table640.1%
.text + .rodata60,26192.0%
.data (LMA)2,7684.2%
Total63,02996.2%

Get exact numbers with:

rust-objdump -h target/thumbv7m-none-eabi/release/NumCore
llvm-size target/thumbv7m-none-eabi/release/NumCore

RAM budget

ResourceSize (bytes)Address Range
.data + .bss2,7760x2000_0000 - 0x2000_0AD8
Unallocated gap3,8800x2000_0AD8 - 0x2000_1A00
.stack (reserved)1,5360x2000_1A00 - 0x2000_2000
SRAM total8,1920x2000_0000 - 0x2000_2000

Debugging with GDB + QEMU

# Terminal 1: start QEMU with GDB stub
qemu-system-arm -M lm3s811evb -serial mon:stdio -display none \
-kernel target/thumbv7m-none-eabi/debug/NumCore -s -S

# Terminal 2: connect GDB
arm-none-eabi-gdb target/thumbv7m-none-eabi/debug/NumCore
(gdb) target remote localhost:1234
(gdb) hbreak numcore::runtime::start
(gdb) continue
(gdb) info registers
(gdb) x/8wx 0x20000000 # examine .bss
(gdb) monitor system_reset # reset from GDB

Binary analysis

# Section sizes
llvm-size target/thumbv7m-none-eabi/release/NumCore

# Symbol sizes (debug build)
rust-nm --print-size target/thumbv7m-none-eabi/release/NumCore | sort -k2 -n -r

# Full disassembly
rust-objdump -d target/thumbv7m-none-eabi/release/NumCore | less

Adding a new math function

1. Add the function token to the lexer

Add a new Token::Func* variant and a match arm in parse_identifier(). The lexer maps the lowercase function name directly to the specific token in one step — there is no intermediate identifier token.

2. Add the function enum variant

Add the function to MathFunction (single-argument), TwoArgMathFunction (two-argument), or ThreeArgMathFunction (three-argument) in parser.rs.

3. Implement the maths

Write the Q31.32 implementation in fixed_point.rs or distributions.rs. Return Option for domain errors. If the function can overflow, call set_overflow_info(log10_est, negative) so the overflow subsystem can display scientific notation.

4. Wire the opcode

In opcodes.rs, add MathFunction::from_u8() index support (the opcode CallFunction = 0x20 takes a function index byte — existing functions use indices 0–23). If adding a truly new opcode, add it to the Op enum and update from_u8()/width().

5. Wire the compiler

In compiler.rs, add a match arm in compile_primary():

Token::FuncMyFunc => compile_single_arg_fn(cursor, bc,
Op::CallFunction as u8, MathFunction::MyFunc as u8)?,

6. Wire the VM

In vm.rs, the Op::CallFunction handler already dispatches via MathFunction::from_u8(fn_idx) — no new match arm in the VM loop needed unless you added a new opcode.

7. Wire the evaluator

Add the match arm in apply_function(), apply_two_arg_function(), or apply_three_arg_function() in evaluator.rs.

8. Add to welcome banner

Add the function name to print_welcome_banner() in runtime/mod.rs.

9. Add tests

Add tests to test-suite/tests/math.rs covering expected values, domain errors, overflow edges, and roundtrip consistency.

Verifying Q31.32 constants

import math
SCALE = 2**32

def to_q3132(x): return round(x * SCALE)
def from_q3132(x): return x / SCALE

FIXED_PI = to_q3132(math.pi)
FIXED_E = to_q3132(math.e)
# Verify:
print(from_q3132(FIXED_PI)) # 3.141592653589793
print(from_q3132(FIXED_E)) # 2.718281828459045

Matching Python reference values

SCALE = 2**32
def to_q3132(x): return round(x * SCALE)
def from_q3132(x): return x / SCALE

# Generate expected values for CORDIC arctan table
for i in range(22):
val = to_q3132(math.atan(2**-i))
print(f"i={i:2d} Q31.32={val}")

# Generate ln_factorial table
for k in range(0, 21):
val = to_q3132(math.lgamma(k + 1))
print(f"k={k:2d} ln({k}!)={math.lgamma(k+1):.10f} Q31.32={val}")