How the board and the clock work
examples/blink.zeno was written on the first day, before there was a
lexer. It is the program Zeno is for:
from board import led
from time import sleep
while true {
led.toggle()
sleep(500 ms)
}
It runs now.
cargo run -- run examples/blink.zeno # stop it with Ctrl-C
cargo run -- build --firmware --no-collector examples/blink.zeno # blink.c, and ./blink
led on
led off
led on
...
A computer has no LED on pin 13, so the board is a simulated one. It prints a line each time a pin changes level, which is what a logic analyser clipped to the pin would show. The same program, compiled, can have a real board put underneath it without changing a line of Zeno. This page is about how both of those work.
Two modules
board |
led |
the pin the LED is on |
pin(7) |
the pin with that number. A ValueError if the board has none |
|
time |
sleep(500 ms) |
wait. The time must have a unit |
now() |
the reading of a clock that only goes forward, in ms |
And what a pin can do:
p.on(), p.off() |
drive it high or low |
p.toggle() |
the other level from the one it has |
p.is_on() |
is it high? A bool |
p.number |
which pin it is. An int |
Pin is a type, so a function can ask for one:
function flash(light: Pin, times: int) {
for i in range(times) {
light.toggle()
sleep(100 ms)
}
}
sleep(500) does not compile, and neither does sleep(3 V). Nobody
can tell whether 500 is seconds or milliseconds, and firmware has been
lost to exactly that. The unit is in the type, so the mistake is found
by the checker and not on the bench.
now() counts from some fixed moment: on a microcontroller, from when
it was switched on. Only the difference between two readings means
anything:
let start = now()
do_the_work()
if now() - start > 20 ms { print("too slow") }
A pin is a number
The first question was what a pin is inside the compiler. There were three candidates:
- An object of a class
Pin, with its level in a field. Every stage already knows objects. But an object lives in memory from the collector, andblinkwould then need a collector to switch an LED. - A plain
int, withon(13)andoff(13)as functions. Free at run time, but thenon(count)compiles, and so doesled + 1. - A kind of its own, which is a number underneath. The checker
keeps pins and ints apart. After the checker, a pin is its number,
and costs what an
intcosts.
Zeno takes the third (decision 3.15). It is what units already do:
500 ms is a double by the time it is compiled, and the checker has
long since made sure nobody added it to a voltage.
So Pin went through the stages like this:
| Stage | What was added |
|---|---|
| Checker | Ty::Pin, its four methods and .number, and Pin as a type name |
| Interpreter and VM | Value::Pin(i64). Both use builtins.rs, so the methods are written once |
| C backend | Kind::Pin is an int64_t. led.on() is zeno_pin_on(13) |
| LLVM backend | Kind::Pin is an i64. led.on() is call void @zeno_pin_on(i64 13) |
In both backends nearly every place that handles a kind needed one
word added: where it says Kind::Int, it now says
Kind::Int | Kind::Pin. The one place a pin differs from an int is how
it is shown: print(led) is Pin(13).
That is why a list of pins, a pin in a class, and Pin or none all
work without anything written for them. They work for ints.
The simulated board
src/board.rs, sixty lines. It is the whole board for zeno run:
pub struct Board {
levels: [bool; PINS as usize],
}
pub fn write(&mut self, pin: i64, level: bool) -> Option<String> {
if self.levels[pin as usize] == level {
return None;
}
self.levels[pin as usize] = level;
Some(format!("{} {}", name(pin), if level { "on" } else { "off" }))
}
Sixty-four pins, all low to begin with, and the LED on pin 13 (where an Arduino Uno has it, which makes it the number most people have met).
A line is printed only when a level changes. led.on() twice
prints led on once. A pin that is already high does not move, and an
analyser would show nothing.
The interpreter and the VM each hold a Board, and reach it through
the Machine trait they already share, which gained one function:
fn board(&mut self) -> &mut crate::board::Board;
A board that belonged to the whole process would be simpler still. But the tests run every program on the interpreter and then on the VM, and the second run must start with every pin low again.
Four functions
A compiled program meets the board at the end of runtime.c, and this
is the part worth reading slowly:
/* zeno_board_write(pin, level) drive a pin high or low
* zeno_board_read(pin) is the pin high?
* zeno_board_sleep(seconds) wait
* zeno_board_now() a clock that only goes forward, in ms
*/
Those four, and ZENO_PINS, the number of pins, are everything Zeno
knows about hardware. What the program calls is written on top of them
once, for every board:
static void zeno_pin_on(int64_t pin) { zeno_board_write((int)pin, true); }
static void zeno_pin_off(int64_t pin) { zeno_board_write((int)pin, false); }
static void zeno_pin_toggle(int64_t pin) { zeno_board_write((int)pin, !zeno_board_read((int)pin)); }
static bool zeno_pin_is_on(int64_t pin) { return zeno_board_read((int)pin); }
pin(n) checks n against ZENO_PINS and raises the ValueError.
zeno_sleep throws away a time that is zero or negative. So a board
never sees a pin it does not have or a wait that makes no sense, and
the four functions can be four lines each.
The simulated board is the same board as the Rust one, in C, and it is there unless something else is asked for:
#ifdef ZENO_BOARD
#include ZENO_BOARD
#else
/* ... the simulated board ... */
#endif
Before it sleeps, the simulated board flushes what has been printed.
Without that, ./blink | cat shows nothing for a long time and then
everything at once, because C saves up output that is not going to a
terminal.
What blink compiles to
All of zeno_program in blink.c:
while (true) {
zeno_pin_toggle(ZENO_LED);
zeno_sleep((500.0 / 1000.0));
}
And in blink.ll, from zeno build --llvm:
do1:
call void @zeno_pin_toggle(i64 13)
%t1 = sitofp i64 500 to double
%t2 = fdiv double %t1, 1000.0
call void @zeno_sleep(double %t2)
br label %while1
The board counts in seconds whatever unit the program wrote, so the
compiler turns 500 ms into seconds on the way, and the C compiler
folds the division to 0.5.
Nothing in it allocates, so --no-collector accepts it, and nothing
in it can raise, so --firmware has nothing to add. A pin being a
number is what makes this true.
Putting a real board underneath
Write the four functions for the board in a header, and tell the C compiler where it is:
cargo run -- build --firmware --no-collector examples/blink.zeno
cc -O2 -DZENO_BOARD='"my_board.h"' -o blink blink.c -lm
The test a_board_of_its_own_can_be_put_underneath does exactly this
with a board of two pins that writes down every call, and whose clock
moves only when the program sleeps:
#define ZENO_PINS 2
static bool my_levels[ZENO_PINS];
static double my_clock = 0.0;
static int my_pin(int pin) { return pin == ZENO_LED ? 0 : pin; }
static bool zeno_board_read(int pin) { return my_levels[my_pin(pin)]; }
static void zeno_board_write(int pin, bool level) {
my_levels[my_pin(pin)] = level;
printf("write %d %d\n", my_pin(pin), (int)level);
}
static void zeno_board_sleep(double seconds) { my_clock += seconds * 1000.0; }
static double zeno_board_now(void) { return my_clock; }
The same flag works for an LLVM build: add it to the clang line.
Two things to see in it. To the Zeno program the LED is always pin 13,
and the board is free to wire that number to whichever pin its LED is
really on (my_pin). And a clock that only moves when asked makes a
program that waits run in no time, which is how firmware is tested on a
desk.
For a real microcontroller the four functions become calls to the vendor's library. With ST's HAL for an STM32, where the LED of a Nucleo board is on port A pin 5, they would look like this. This is a sketch. It has not been run on hardware.
#define ZENO_PINS 16
static void zeno_board_write(int pin, bool level) {
if (pin == ZENO_LED) pin = 5;
HAL_GPIO_WritePin(GPIOA, (uint16_t)(1u << pin), level ? GPIO_PIN_SET : GPIO_PIN_RESET);
}
static bool zeno_board_read(int pin) {
if (pin == ZENO_LED) pin = 5;
return HAL_GPIO_ReadPin(GPIOA, (uint16_t)(1u << pin)) == GPIO_PIN_SET;
}
static void zeno_board_sleep(double seconds) { HAL_Delay((uint32_t)(seconds * 1000.0)); }
static double zeno_board_now(void) { return (double)HAL_GetTick(); }
zeno build does not run the cross compiler for you. It writes C, and
building C for a target is a job the target's own tools already do well
(decision 5.12c). The reasons for the four functions are in 5.12.
The tests
pins_and_the_clock_matchruns one program through everything: the interpreter, the VM, C, firmware C and LLVM. It puts pins in a list, in a class and in aPin or none.a_pin_the_board_does_not_have_is_an_errorchecks theValueError, caught and uncaught, at the same line and column everywhere.a_board_of_its_own_can_be_put_underneathis the header above, on all three kinds of C build.blink_is_three_lines_of_c_and_needs_no_collectorholds the C forblinkto exactly the three lines shown.examples/signal.zeno, a traffic light as a state machine, is built every way, including with no collector.
No test can run blink itself, because it never ends. The test that
runs every example steps over it.
A clock is hard to test: two runs never read the same time. So the
programs in the tests print only what must be true of any clock, such
as now() - start >= 200 ms after sleeping that long.
What it does not do yet
- Inputs.
is_on()reads back what the program wrote. There is no button, and no way to say a pin is an input. - Everything else on a microcontroller: interrupts, timers, PWM, an ADC, a serial port. Each would be a few more functions in the board layer.
- The runtime is still a desktop runtime. A firmware build with no
collector has no
setjmp, no garbage collector and nomalloc(pool.md), but it still callsprintfandexit(no-collector.md). The board layer is the first piece of a port, and not the whole of it. now()is adoubleof milliseconds. That is exact for thousands of years, and slow on a processor with no floating point. Whole numbers with a size, such asu32, came in the stage after this one (sized-ints.md). The clock has not been moved onto them yet.
Things to try
- Run
examples/signal.zeno, then change oneholdto return40with no unit. Read the message. Now try40 V. led.on()twice prints one line. Find the two places that decide that, one in Rust and one in C. Change both to print every write. Which tests fail, and is the new output more useful or less?- Add
buttonto theboardmodule: a pin whose level the program reads but does not drive. On the simulated board, let it be pressed every third time it is read. You will need a fifth function in the board layer, or will you? - Write
my_board.hfor a board with eight pins where every write prints all eight levels as one byte in binary, such as00100000. Buildsignal.zenoon it by hand with-DZENO_BOARD. sleeptakes any unit of time. Find where500 msbecomes seconds inemit_c.rs(device). What doessleep(2 min)compile to? Where is the multiplication done: in Zeno's compiler, in the C compiler, or when the program runs?- Add
p.pulse(10 ms): on, wait, off. It can be written in the runtime on top of the four functions, with nothing new in the board layer. Do it in the interpreter first, then C, then LLVM. The testpins_and_the_clock_matchwill hold all five to the same answer. now() - startis afloatsubtraction. On a small processorHAL_GetTick()is auint32_tthat wraps round after 49 days. What doesnow() - startgive across the wrap ifzeno_board_nowjust returns it as adouble? How would you fix it inside the board layer, so that Zeno programs never see it?- A big one: an interrupt.
on_rising(pin(2), function() { ... })would run a Zeno function when a pin goes high. What may that function not do? (Think about the collector, and aboutprint.) How could the checker enforce it?
This page is docs/board.md in the language repository.