How the bit operators and the sized whole numbers work
Firmware talks to hardware in bytes. A sensor sends six of them, a register is thirty-two bits wide, and bit 3 of it turns something on. This stage gives Zeno the two things that work needs:
- Bit operators:
&,|,^,~,<<,>>. - Whole numbers with a size:
u8,u16,u32,i8,i16,i32.
examples/packet.zeno uses both. It packs sensor readings into
six-byte frames and unpacks them again:
cargo run -- run examples/packet.zeno
cargo run -- build examples/packet.zeno # packet.c, and ./packet
cargo run -- build --llvm examples/packet.zeno # packet.ll, and ./packet
a5 03 00 d7 fe 7d Reading(sensor=3, flags=0, value=215, count=254) true
a5 83 03 ff ff 29 Reading(sensor=3, flags=8, value=1023, count=255) true
alarm on sensor 3
a5 c3 fa 00 00 62 Reading(sensor=3, flags=12, value=64000, count=0) true
alarm on sensor 3
the checksum should be a6, but the frame says 9b
The page has two parts, because the stage was built in two commits.
The reasons for every choice are in decisions.md,
2.7 and 2.8 for the language and 5.13 for how it is built.
Part one: the bit operators
a & b |
and | a bit is 1 where both have a 1 |
a \| b |
or | a bit is 1 where either has a 1 |
a ^ b |
exclusive or | a bit is 1 where the two differ |
~a |
not | every bit turned over |
a << n |
shift left | a times two, n times |
a >> n |
shift right | a halved, n times, rounding down |
Each has a form that updates a variable: reg |= 1 << 3.
An int is 64 bits in two's complement, the way every processor made
in the last fifty years holds a signed number. So ~x is -x - 1, and
-1 is sixty-four ones: -1 & 0xFF is 255.
Through the stages
A new operator touches every stage, a little. That makes it a good way to walk the whole compiler again.
The lexer (src/lexer.rs) gets eleven new tokens. The only care
needed is where one is the start of another. < could be <, <=,
<< or <<=, so the lexer looks one character further each time:
'<' => {
if self.eat('<') {
if self.eat('=') { ShlAssign } else { Shl }
} else if self.eat('=') {
LtEq
} else {
Lt
}
}
&& and || were already errors with a hint ("Zeno spells this
and"). They still are. The check for the double character simply
comes before the single one.
The parser (src/parser.rs) gets four new precedence levels,
between the comparisons and +:
or → and → not → comparison → | → ^ → & → << >> → + - → * / // % → - ~ → **
Every level in this parser is a function with the same loop in it
(parser.md). Four more copies of the loop would be four
chances for a slip, so there is one function that takes the level as a
number:
fn bits_at(&mut self, level: usize) -> Result<Expr, Error> {
if level == 4 {
return self.sum(); // past the last bit level: arithmetic
}
let mut left = self.bits_at(level + 1)?;
loop {
let op = match (level, self.peek()) {
(0, TokenKind::Pipe) => BinaryOp::BitOr,
(1, TokenKind::Caret) => BinaryOp::BitXor,
(2, TokenKind::Amp) => BinaryOp::BitAnd,
(3, TokenKind::Shl) => BinaryOp::Shl,
(3, TokenKind::Shr) => BinaryOp::Shr,
_ => return Ok(left),
};
self.advance();
let right = self.bits_at(level + 1)?;
left = Self::binary(left, op, right);
}
}
~ joins unary minus in negation. Nothing else in the parser
changed: the tree already had Binary and Unary nodes, and these
are five more operators and one more.
The checker (src/checker.rs) allows them on whole numbers only.
Two bools are the likely slip, from someone who writes C, so that case
gets its own sentence:
error: `&` works on the bits of whole numbers, and these are bool and bool.
For two bools Zeno spells this `and`
What they do is written once, in src/ops.rs, which the
interpreter and the VM share (vm.md). &, |, ^ and ~
are Rust's own operators on an i64. The shifts are the interesting
ones, below.
The C backend writes (l & r), (l | r), (l ^ r) and (~x)
straight into the C. The LLVM backend writes and i64, or i64
and xor i64. IR has no "not" instruction, and needs none: ~x is
xor i64 %x, -1, because exclusive-or with all ones turns every bit
over.
Why not C's precedence
In C, this line is a bug:
if (flags & MASK == 0) { ... }
== binds tighter than &, so it means flags & (MASK == 0). It
compiles without a word. The people who made C said later that it was
a mistake, kept from an older language where & also did the job of
&&.
Python moved the bit operators above the comparisons, and Zeno copies it. So in Zeno the line means what it looks like, and the tests pin it down:
print(flags & mask == 5, 1 << 2 + 1, 6 | 1 ^ 3 & 2)
1 << 2 + 1 is 1 << 3: arithmetic is tighter than a shift, in C,
Python and Zeno alike.
A shift that cannot go quietly wrong
In C, three things can happen to a shift and none of them is an error:
1 << 40on a 32-bitint: undefined.x << 64orx << -1: undefined.-5 >> 1: whatever the compiler likes.
Zeno already checks + and * for overflow (2.2). A left shift is a
multiplication by a power of two, so it gets the same treatment, and a
shift count must be 0 to 63. This is the whole of it, from the runtime
that every compiled program carries (src/runtime.c):
static int64_t zeno_shl(int64_t a, int64_t n, int line, int col) {
zeno_shift_count(n, line, col);
int64_t shifted = (int64_t)((uint64_t)a << n);
if ((shifted < 0 ? ~(~shifted >> n) : shifted >> n) != a) zeno_overflow(line, col);
return shifted;
}
static int64_t zeno_shr(int64_t a, int64_t n, int line, int col) {
zeno_shift_count(n, line, col);
return a < 0 ? ~(~a >> n) : a >> n;
}
Three small tricks are in there.
- The shift is done on the unsigned number. C says exactly what
<<does to auint64_t: the bits that leave the top are gone. On a signed number it says nothing. - "Did it overflow?" is "does shifting back give what I started with?" If any bit that mattered fell off the top, it does not.
- A negative number is shifted right upside down.
~ais not negative whenais, so~a >> nis safe, and turning the bits back over gives the answer that rounds down:-5 >> 1is-3, the same as-5 // 2.
The interpreter does the same thing in Rust, in a dozen lines of
ops.rs. Rust's >> on an i64 already rounds down, so only the
shift-back test is needed there.
The LLVM backend calls these two functions too, where it writes + and
* in IR itself: a shift is rare enough that a call costs nothing
worth saving.
Part two: whole numbers with a size
| bits | holds | |
|---|---|---|
u8 |
8 | 0 to 255 |
u16 |
16 | 0 to 65535 |
u32 |
32 | 0 to 4294967295 |
i8 |
8 | -128 to 127 |
i16 |
16 | -32768 to 32767 |
i32 |
32 | -2147483648 to 2147483647 |
int is still 64 bits, and still what you get when you write nothing.
The rules
There are four, and the rest of this part is how each is built.
let a: u8 = 200 # 1. a number written out takes the size it is put in
let b: u8 = 100
let total = a + b # 2. arithmetic gives an int: 300, never 44
let c: u8 = u8(total) # 3. u8( ) checks. This one raises OverflowError
let d: u8 = u8.wrap(total) # 4. u8.wrap( ) keeps the low bits: 44
And one about going the other way: a u8 can be used wherever an
int is wanted, with nothing written, because every u8 is an int.
The thinking behind rule 2 is the heart of the design, so here it is
again in short (decision 2.8a has it in full). C and Rust do the sum
at the small size. Then either 200 + 100 is quietly 44, or
(a + b) // 2 fails for two numbers whose average fits. Zeno does
every sum on full ints, so the middle of an expression can never
overflow at 255. The one place a number may not fit is the place it is
stored, and there the program says which it wants: a check, or the low
bits.
The cost is that count += 1 does not compile for a u8:
error: the new value should be u8, but this is int.
Convert it with `u8(...)`, which checks that it fits
Write count = u8(count + 1), or u8.wrap(count + 1) for a counter
that is meant to go round.
One file knows the sizes
src/ints.rs is about 120 lines, tests included, and it is the only place that knows what a
u8 holds:
pub struct Width {
pub name: &'static str, // "u8"
pub wrap: &'static str, // "u8.wrap"
pub bits: u32,
pub signed: bool,
}
pub const WIDTHS: [Width; 6] = [ ... ];
impl Width {
pub fn min(self) -> i64 { if self.signed { -(1 << (self.bits - 1)) } else { 0 } }
pub fn max(self) -> i64 { ... }
pub fn holds(self, n: i64) -> bool { (self.min()..=self.max()).contains(&n) }
pub fn wrap(self, n: i64) -> i64 { ... }
}
The checker, the interpreter and both compilers ask it. A seventh
size would be one more line in WIDTHS.
A sized number is an ordinary int with a promise
This is the idea that made the stage small. Look at what rule 2 says:
arithmetic on sized numbers gives an int. So once a u8 exists,
there is nothing it does differently from an int. It is added like
one, printed like one, compared like one.
So it is one. In the interpreter a u8 is a Value::Int. In C it is
an int64_t. In LLVM it is an i64. The size is a promise that the
number is in range, and the promise is kept in exactly two places:
u8( ) and u8.wrap( ), which make one, and the checker, which lets
nothing else into a u8.
Units work this way (500 ms is a double), and so do pins (led is
its number). The type stops the mistake, and then it is gone.
The other design would be a Value::U8, and with it a new row in
every operator, in four engines. That is a lot of code whose whole job
is to agree with the code beside it.
In the checker
The checker gets one new type, Ty::Sized(Width), and three small
rules.
Arithmetic sees an int. widened turns a sized type into int,
and binary applies it to both sides before it does anything else:
fn widened(&self) -> Ty {
match self {
Ty::Sized(_) => Ty::Int,
other => other.clone(),
}
}
After that line the whole of binary is unchanged. It only ever sees
ints, and answers int. The built-in functions do the same to their
arguments, which is why abs, max and sum work on sized numbers
without knowing they exist.
A sized number fits where an int is wanted. One line in fits:
(Ty::Sized(_), Ty::Int) => true,
There is no line the other way round, so an int going to a u8 is
the ordinary "should be u8, but this is int" error, with a hint added
for the fix.
A list[u8] does not fit a list[int], though each item would.
Lists are shared: whoever held it as a list[int] could append 300,
and the first holder would then find a u8 that is not one. This is
the one place the checker has to think about it:
(Ty::List(from), Ty::List(to)) => !sized_into_int(from, to) && fits(from, to),
A number written out adapts. let b: u8 = 7 must not need
u8(7). But 7 is an int, and ints do not fit. So before the
checker asks whether a value fits, it asks whether the value is a
number that is written there:
enum Written {
Number(i128),
List(Vec<Written>),
}
written(expr) gives one for 7, for -1, and for [1, 2, 0xFF].
If the place wants a sized type, the number is checked against the
size there and then, and takes the type:
let b: u8 = 300
error: 300 does not fit in a u8 (0 to 255)
That is an error before the program runs, and it costs nothing when
it does. It works in every place a value can go: a let, an
assignment, an argument, a return, a default, an item appended to a
list.
A const is not a written number. const START = 0xA5 is an int.
Give it the size: const START: u8 = 0xA5.
u8 is two things with one name. It is a type, and it is the
built-in function that converts, u8( ).
The checker finds the type through the function: a type name that
resolves to the built-in u8 is the sized type. That is why a class
of your own called u8 wins, with no special case. u8.wrap is a
field of the built-in, the same way math.sqrt is a field of a
module.
Making one: u8( ) and u8.wrap( )
In the interpreter both are a few lines in src/builtins.rs:
fn fit_in(width: Width, args: Vec<Value>, span: Span) -> Result {
arity(width.name, &args, 1, 1, span)?;
match &args[0] {
Value::Int(n) if width.holds(*n) => Ok(Value::Int(*n)),
Value::Int(n) => raise("OverflowError", width.does_not_fit(n), span),
other => wrong_type(width.name, "a whole number", other, span),
}
}
Look at what comes back: Value::Int(*n). The same number that went
in. All u8( ) does is look.
wrap is the one piece of real arithmetic, in ints.rs:
pub fn wrap(self, n: i64) -> i64 {
let low = n & ((1 << self.bits) - 1);
if self.signed && low > self.max() { low - (1 << self.bits) } else { low }
}
(1 << 8) - 1 is 255, eight ones, so the & keeps the low eight
bits. For a signed size the top one of those bits counts as minus, so
200 (which has it set) becomes 200 - 256, which is -56. That is
what the bits 11001000 mean when they are read as an i8.
There is a Rust detail worth a look. A built-in is a plain function pointer, and a function pointer cannot remember which size it is for. So there has to be one function for each size. They are stamped out by a number in the type:
fn fit<const N: usize>(_: &mut dyn Machine, args: Vec<Value>, span: Span) -> Result {
fit_in(ints::WIDTHS[N], args, span)
}
const FITS: [Call; 6] = [fit::<0>, fit::<1>, fit::<2>, fit::<3>, fit::<4>, fit::<5>];
fit::<0> is a different function from fit::<3>, made by the
compiler from the one written here. This is called a const generic.
In C, the two are runtime functions:
static int64_t zeno_fit(int64_t n, int64_t min, int64_t max, const char *size, int line, int col) {
if (n < min || n > max) {
zeno_fail("OverflowError", zeno_join(8, zeno_str_int(n), " does not fit in ", size, ...), line, col);
}
return n;
}
static int64_t zeno_wrap(int64_t n, int bits, bool is_signed) {
uint64_t low = (uint64_t)n & ((UINT64_C(1) << bits) - 1);
if (is_signed && (low >> (bits - 1)) != 0) return (int64_t)low - (INT64_C(1) << bits);
return (int64_t)low;
}
So this line of packet.zeno:
u8(reading.flags << 4 | reading.sensor)
compiles to this:
zeno_fit((zeno_shl(((int64_t)z2_reading->z_flags), 4, 42, 12) | ((int64_t)z2_reading->z_sensor)),
INT64_C(0), INT64_C(255), "a u8", 42, 9)
In LLVM, u8( ) calls the same zeno_fit. u8.wrap( ) needs no
call at all. IR has an instruction that keeps the low bits and one
that widens again, and those two are the whole of it:
%t9 = trunc i64 %t7 to i8 ; keep the low eight bits
%t10 = zext i8 %t9 to i64 ; and make it an i64 again, with zeros
For a signed size the second is sext ("sign extend"), which fills
the new bits with copies of the top one. That is the low - 256 of
the Rust version, done by the processor in one instruction.
Where the size is real: a field of an object
If a u8 is an int64_t everywhere, what was the point? The point is
this struct, from packet.c:
struct z_Reading {
uint8_t z_sensor;
uint8_t z_flags;
uint16_t z_value;
uint8_t z_count;
};
Six bytes (five, and one of padding), where four plain ints would
take thirty-two. In LLVM it is %z_Reading = type { i8, i8, i16, i8 }.
A field is the one place a sized number is stored small. Memory is
spent on what there are many of, and what there are many of lives in
objects. A local variable lives in a register, where a u8 saves
nothing.
The checker tells the compilers which fields are sized
(Types::widths), and each compiler makes the value small on the way
in and an ordinary int again on the way out. In C:
self->z_sensor = (uint8_t)z_sensor; /* in: the constructor */
... ((int64_t)z_p->z_kind) ... /* out: every read */
That cast on the way out is not decoration. Without it, C would do
arithmetic on a uint32_t field as unsigned arithmetic. -field
would be a huge positive number, and field < -1 would be true. The
cast is what makes rule 2 hold in C. A test has a u32 field holding
4294967295 and prints ~q.big, -p.big and p.big < -1 for this
reason.
In LLVM the same two steps are the trunc, and zext or sext, that
wrap uses. They live in two functions of src/emit_llvm.rs,
load_field and store_field, and every read or write of a field
goes through one of them:
%t3 = getelementptr %z_Packet, ptr %t2, i32 0, i32 0
%t4 = load i8, ptr %t3
%t5 = zext i8 %t4 to i64
The trunc on the way in cannot lose anything: the checker made sure
the number is in range before it got there.
What it does not do yet
- A
list[u8]is not packed. Each item still takes eight bytes, because the runtime has one list type for everything (compiler.md). A packed list, or abytestype, is its own stage. - No
u64. Half its values do not fit in anint, and then "the sum is an int" stops being true (decision 2.8d). - A
u8does not go where au16is wanted withoutu16( ), though it always fits. The rule today is "sized to int is free, everything else is written". It could be loosened. - No
u8.maxoru8.min.ints.rsknows them. Nothing lets a program ask. - Variables, parameters and enum cases are not small, only fields of classes.
- In a program that is not strict, a value whose type is unknown
until run time can land in a
u8unchecked, as it can land in aninttoday. Compiled programs are always strict. - In a firmware build a
u8( )that does not fit stops the program, like any overflow (firmware.md). It cannot be caught there. Test first, or usewrap.
The tests
Every program in the tests runs on the interpreter and the VM, then compiled through C, C for firmware, and LLVM, and all five must print the same thing. The new ones:
ints::tests: the six ranges, andwrapat each edge.checker::tests::sized_ints_are_types,a_written_number_must_fit_its_size,an_int_does_not_become_sized_by_itself,a_size_left_out_is_worked_out: what is allowed, and the exact words of every refusal.interpreter::tests::sized_intsanda_sized_int_must_fit.emit_c::tests::sized_ints_match: one long program with every operator on sized numbers, every conversion at its edges, sized fields of every size, lists, defaults andor none.emit_c::tests::a_sized_field_is_small_in_the_structandemit_llvm::tests::the_ir_for_a_sized_field: the struct really is small, and the casts are where this page says.bit_operators_matchanda_shift_too_far_is_an_error, from part one, checked against Python.
Things to try
- In
packet.zeno, make a reading withflags=16. Which line refuses it, and is it refused before the program runs or while it does? Now passflags=u8(16). What changed, and why? - Change
checksumto returnu8(total)and run it. Which frame fails? Change it back and say in one sentence why a checksum is the textbook case forwrap. 1 << 2 + 1is 8. Changebits_atso that the shifts bind tighter than+, run the tests, and read which ones fail. Put it back.- Add
u8.maxandu8.min. The checker'sfieldandops.rs'sfieldalready handleu8.wrap, so start beside those. They are constants, so what should the two compilers write for them? - Add
>>>, a right shift that brings in zeros and not copies of the sign. Follow part one's list of stages. Is it needed at all, now thatu32exists? Try writing it withu32.wrapand>>. - Allow a
u8to go where au16is wanted. It is one more arm infits. Write down the full table of which size fits in which first: doesu8fiti8? Doesi8fitu16? Then decide whether the table is worth teaching to everyone who reads a Zeno program. - Build
packet.zenoand printsizeof(z_Reading)from the C (add aprintftomainby hand). Then put au32field first in the class and look again. Then put it last. This is "struct padding", and it is why field order matters in firmware. - Find
load_fieldinemit_llvm.rs. Changesexttozextfor every size and run the tests. Which value in which test goes wrong, and what does it print? - Write
rotate_left(x: u8, n: int) -> u8, which shifts left and brings the bits that fall off back in at the bottom. You need<<,>>,|and onewrap. Check it against0b1000_0001rotated by one. - A big one: make
list[u8]take one byte an item. The list inruntime.choldszeno_slotitems, each eight bytes. What would a second list type need from the runtime, and how many functions inemit_c.rslook inside a list? Count them before you start.
This page is docs/sized-ints.md in the language repository.