How compiling to C works
zeno run reads your program and does what it says, one node of the syntax
tree at a time. zeno build reads the same tree and writes a C program that
does what it says. Then the machine's C compiler turns that into a real
program: no Zeno needed to run it, and many times faster.
zeno build examples/primes.zeno # writes primes.c, then builds ./primes
./primes
source -> lexer -> parser -> checker -> emit_c -> primes.c -> cc -> ./primes
Open primes.c after building. Reading the C next to the Zeno is the best
way to follow this document.
The files
src/emit_c.rswalks the tree and writes C. It is the compiler proper.src/runtime.cis a small C library that is pasted in front of every program: checked arithmetic, strings, lists, errors,print.
One rule above all: build must agree with run
A program must print the same thing either way. The tests at the bottom of
emit_c.rs enforce it: each one runs a program through the interpreter,
compiles the same program, runs the result, and compares the output byte for
byte, including the error message when the program is meant to fail. If you
change one side, the tests tell you to change the other.
Since the bytecode VM (vm.md) the same tests run each program a
third way, on the VM, which must agree with the interpreter as well.
What it compiles so far
Nearly all of the language: numbers, bools, strings and their methods,
quantities, variables, if / while / for, functions, f-strings,
print, the math, time and board modules, classes with methods, lists (literals,
indexing, append, pop, +, in, comprehensions, map, filter,
reduce), enums and match, values that may be none, errors (try,
except, raise), functions used as values, and owned values
(docs/ownership.md).
Every program in examples/ builds. blink.zeno and signal.zeno run on
a simulated board (docs/board.md).
A few things are left. zeno build names each one and points at the line:
error: `zeno build` cannot compile a function inside a function or block yet. `zeno run` can still run this program
- A named function, class or enum declared inside a function. (Write
let helper = function(n) { ... }instead: that one compiles.) - A default value that is not a plain literal or
[]:function f(at = now()). - A method taken off an object without calling it:
let f = point.length.
Where the types come from
C wants a type on everything. Zeno lets you write none:
function average_gap(limit) {
return limit / count_primes(limit)
}
static double z_average_gap(int64_t z3_limit) {
return zeno_div((double)z3_limit, (double)z_count_primes(z3_limit), 34, 12);
}
The checker has already worked out that limit is an int (from the call) and
that the function returns a float (from the /). It passes every function's
signature to the compiler in Types. From those, the compiler works out the
rest as it goes: a literal knows its own type, a variable has the type of its
first value, a + b has the type its two sides give.
Each compiled expression is a Val: a piece of C text (code) and its
kind. arithmetic looks at the two kinds to choose what to write. Two ints
become zeno_add(a, b, ...); an int and a float become ((double)a + b).
This is why zeno build always checks in strict mode: a value whose type is
not known cannot be given a C type.
The runtime: making C keep Zeno's promises
C is looser than Zeno. Three examples:
| Zeno says | C says | |
|---|---|---|
a + b overflows |
OverflowError | undefined; anything may happen |
a // 0 |
ZeroDivisionError | undefined |
-7 // 2 |
-4, rounds down |
-3, rounds toward zero |
So whole-number arithmetic goes through functions in runtime.c. zeno_add
asks the processor whether the addition overflowed, and stops the program
with Zeno's error if it did. The last two arguments of each call are the line
and column in the Zeno source, so the message can say where:
error: uncaught ZeroDivisionError: division by zero
--> primes.zeno:15:12
Floats need less: C's float arithmetic already matches, apart from dividing
by zero. But printing them does not match. zeno_str_float is the longest
function in the runtime, because "the shortest digits that read back as the
same number, and always with a .0" is not something C's printf can do in
one step.
Order: the part C leaves open
print(next() - next())
Zeno promises left to right. C promises nothing about which operand of -,
or which argument of a call, is worked out first. If next() prints or
changes a variable, a C compiler may give a different answer from zeno run,
and a different answer on a different machine.
So every Val carries three flags: does it read a top-level variable, can
it fail, does it call a function. sequence compiles a list of
expressions that Zeno orders left to right, and whenever a later one could
disturb an earlier one, it moves the earlier one into a temporary:
int64_t t1 = z_next();
zeno_print(1, zeno_str_int(zeno_sub(t1, z_next(), 9, 7)));
When nothing can be disturbed, as in a + b * 2, no temporary is made and
the C reads like the Zeno.
and, or and chained comparisons (a < f() < g()) have a second concern:
the right side must not run at all unless it is needed. C's && does that
when the right side is a single expression. When the right side needed
statements of its own, logic wraps them in an if.
Classes
class Point {
x: float
y: float
function distance_to(self, other: Point) -> float { ... }
}
struct z_Point { double z_x; double z_y; };
static z_Point *zeno_new_Point(double z_x, double z_y); /* Point(3.0, 4.0) */
static const char *zeno_show_Point(z_Point *self); /* for print */
static bool zeno_same_Point(z_Point *a, z_Point *b); /* for == */
static double z_Point_distance_to(z_Point *z_self, z_Point *z_other);
A class is a C struct. The compiler writes three functions for it that you never wrote: one makes an object, one shows it as text, one compares two. A method is an ordinary C function whose first argument is the object.
An object is always handled through a pointer. That is what makes
let b = a a second name for the same object, as Zeno says it is: copying
a pointer does not copy what it points to.
A field with a size, such as level: u8, is a uint8_t in the struct and
an int64_t everywhere else. sized-ints.md follows one
through.
A generic class, such as class Stack[T], is not compiled itself. Each
type it is used with gets a struct of its own: z_Stack_int,
z_Stack_str. generics.md has that.
Enums and match
enum Shape {
Circle(radius: float)
Rect(width: float, height: float)
Nothing
}
struct z_Shape {
int tag; /* which case: 0, 1 or 2 */
union {
struct { double z_radius; } c0;
struct { double z_width; double z_height; } c1;
} as; /* room for the biggest case */
};
A value of an enum is one case at a time, so the cases can share the same
memory: that is what a C union is. The tag says which case is in there
now. This pair, a tag and a union, is how every language with enums like
these stores them.
match then becomes a chain of ifs on the tag. A pattern such as
Rect(w, h) gives the compiler two things: a test (tag == 1) and new
variables to declare (w and h, read out of the union). pattern
collects both, and works the same way for a pattern inside a pattern.
Because the checker has already made sure every case is covered, the last
case needs no test and becomes a plain else.
Lists
C has no list that grows, so the runtime provides one: a length, how much
room there is, and a pointer to the items. append moves the items to a
place twice the size when the room runs out.
A list must hold ints, or floats, or strings, or objects. C would want a
separate list type for each. Instead every item sits in a zeno_slot, a
union big enough for any single Zeno value:
typedef union { int64_t i; double f; bool b; const char *s; void *p; } zeno_slot;
The compiler knows what each list holds, so it knows which member to read:
xs[0] on a list[int] becomes (*zeno_list_at(z_xs, 0, 3, 7)).i.
zeno_list_at is where the position is checked, so xs[99] is an
IndexError, not a read of some other memory.
Showing a list and comparing two lists need to know how to show or compare
one item. The runtime cannot know that, so the compiler writes a small
function per kind of item (zeno_item_repr_float) and passes it in.
A for loop over a list walks a copy of it. That matches zeno run, where
the loop sees the list as it was when the loop began, even if the body adds
to it. Only the loop ever points at the copy, so it is freed as soon as the
loop ends.
Values that may be none
str or none has to be stored somehow. There are two cases.
A str, a list, an object, an enum value, an error and a function are all
pointers in C already, and a pointer has a spare value: NULL. So for
those, none is NULL, and T or none costs nothing more than T.
A number or a bool has no spare value: every pattern of bits is some
number. So int or none is a number with a flag beside it:
typedef struct { bool present; int64_t value; } zeno_opt_int;
function half(n: int) -> int or none {
if n % 2 == 0 { return n // 2 }
return none
}
let h = half(8)
if h is not none { print(h + 1) }
static zeno_opt_int z_half(int64_t z_n) {
if (zeno_mod(z_n, 2, 2, 8) == 0) {
return (zeno_opt_int){true, zeno_floordiv(z_n, 2, 2, 28)};
}
return (zeno_opt_int){false, 0};
}
...
if (z_h.present) {
zeno_print(1, zeno_str_int(zeno_add(z_h.value, 1, 6, 26)));
}
Three things to see there:
return n // 2puts the flag on, because the function returnsint or none. Going fromTintoT or none, and writingnoneitself, is the one place the two differ, andcoerceinemit_c.rsdoes it wherever a value arrives somewhere: areturn, an argument, an assignment, an item of a list.h is not nonereads the flag.- Inside the
if,his read asz_h.value, the plain number. The compiler cannot see for itself that theifhas rulednoneout. The checker can (that is narrowing, indocs/checker.md), and it passes on a note of every place where a variable is read after such a check.
One more case. A top-level var, or a var shared with an inner function,
can be changed by a function that is called between the check and the use:
var limit: int or none = 5
function clear() { limit = none }
if limit is not none {
clear()
print(limit + 1) # limit is none again by now
}
For those variables only, the value is looked at once more where it is
used (zeno_still_int), and the program stops with an error if it has
gone. A local variable that nothing else can reach needs no second look.
A list gives every item one slot, which has no room for a flag. In a list,
a number that may be none is kept as a pointer to a copy of the number.
Errors: try, except and raise
try {
let port = parse_port(text)
print(port)
} except ValueError as err {
print(f"error: {err}")
}
zeno_try t1; // a place to come back to
t1.outer = zeno_trying;
zeno_trying = &t1; // from here on, errors come to t1
if (setjmp(t1.place) == 0) {
z_port = z_parse_port(z_text);
zeno_print(1, zeno_str_int(z_port));
zeno_trying = t1.outer; // finished with no error
} else if (zeno_caught("ValueError")) {
zeno_error *z_err = zeno_raised;
zeno_print(1, zeno_join(2, "error: ", z_err->message));
} else {
zeno_raise_again(); // not ours: pass it outward
}
setjmp is a C library function that remembers "here": where the program
is, and what is in the processor's registers. It returns 0. Later,
longjmp puts all of that back, and the program finds itself returning
from that same setjmp a second time, now with 1.
So a try remembers a place, and tells the runtime about it by putting it
at the front of a chain (zeno_trying). raise, or any error the runtime
finds itself (dividing by zero, a bad index), goes through one function,
zeno_raise. If the chain is empty, nobody is trying: it prints the error
and stops the program, exactly as before. Otherwise it takes the innermost
place off the chain and jumps to it, out of however many function calls
are in the way.
That means code outside a try pays nothing, and a try in which nothing
goes wrong pays for one setjmp.
Details that the compiler looks after:
- Leaving early. A
return,breakorcontinueinside atrybody jumps past the line that takes the place off the chain. So the compiler writes that line before each of them. - Variables changed inside a
try. C has a rule here: after the jump back, a local variable that was changed since thesetjmpmay hold its old value or its new one, unless it is declaredvolatile. (The C compiler may have kept the new value in a register, and the jump puts the registers back.) The compiler finds the variables that atrybody assigns to, and declares thosevolatile. Seechanged_in_try. - Raising an object.
raise Timeout(30 ms)wraps the object in an error whose kind is the class's name.except Timeout as tgets the object back out.except Error as egets the error it travelled in.
In a firmware build
zeno build --firmware compiles all of this differently, with no
setjmp: a function that can raise returns true or false, and each
caller tests what came back. docs/firmware.md is the
walkthrough.
Functions as values
A function held in a variable is a pointer to a small struct:
typedef struct zeno_closure { zeno_code code; const char *name; } zeno_closure;
code is the C function to run. Calling f(3) becomes "call f's code,
passing f itself first":
((int64_t (*)(zeno_closure *, int64_t))z_f->code)(z_f, 3)
The long bracket in front turns the stored pointer back into the type it
really is, which the compiler knows from the type of f.
Why pass the function to itself? Because of this:
function make_scaler(k: int) {
return function(n: int) { return n * k }
}
The inner function uses k, which belongs to a call of make_scaler that
has finished by the time the inner function runs. So when the inner
function is made, the variables it uses are packed up with it:
struct zeno_env_1 { zeno_closure base; int64_t z_k; };
static int64_t zeno_fn_1(zeno_closure *zeno_self, int64_t z_n) {
struct zeno_env_1 *env = (struct zeno_env_1 *)zeno_self;
return zeno_mul(z_n, env->z_k, 2, 38);
}
static zeno_closure *z_make_scaler(int64_t z_k) {
struct zeno_env_1 *t1 = (struct zeno_env_1 *)zeno_alloc(sizeof(struct zeno_env_1));
t1->base.code = (zeno_code)zeno_fn_1;
t1->base.name = "";
t1->z_k = z_k;
return (&t1->base);
}
A function together with the variables it took with it is called a closure. The header comes first in the struct, so a pointer to the whole thing is also a valid pointer to the header, and every function value can be handled the same way whatever it carries.
How does the compiler know which variables to pack? While it compiles the
inner function, every variable of the outer one is on offer as
env->name, and each notes whether it was looked up. The ones that were
are the ones to pack.
Sharing a variable. k above is copied, which is fine because it never
changes. This is different:
function counter() {
var count = 0
return function() {
count += 1
return count
}
}
count changes, and the change has to last. So a var that an inner
function uses is not kept in a C variable at all. It is kept in a box on
the heap, and the outer function and the inner one both hold a pointer to
the box:
int64_t *z_count = (int64_t *)zeno_alloc(sizeof(int64_t));
*z_count = 0;
...
t2->z_count = z_count; // the inner function gets the box
Ordinary functions as values. xs.map(double) passes a top-level
function. It does not take the hidden first argument, so the compiler
writes a small wrapper that does (zeno_wrap_double) and one fixed
zeno_closure that points at it.
map, filter and reduce are loops written out where they are
used, each calling the function on every item.
Names
Every Zeno name gets z_ in front: count becomes z_count. That way no
Zeno name can collide with a C keyword (double, int) or a C library
function (printf, abs). A name declared a second time, in another block
or function, becomes z2_count. Temporaries the compiler invents are t1,
t2, and everything in the runtime starts with zeno_.
Top-level code
C has no code outside functions. The statements at the top of a Zeno file
become the body of one C function, zeno_program, which main calls.
(main itself only notes where the stack begins, for the garbage
collector.) Variables declared at the top become C globals, so that
functions can see them, and are given their values inside zeno_program,
in the order the file says.
Memory
A string built while the program runs (a + b, an f-string) needs memory,
and so does every object, list and enum value that carries data, every
function made inside another one, and the box of a variable they share.
The runtime takes it from malloc, and a garbage collector in the runtime
frees it once the program can no longer reach it.
docs/memory.md is the walkthrough.
A value marked owned does not wait for the collector. The compiler writes
a call that frees it at the end of its owner's block, and on every other way
out of that block: return, break, continue, and an error.
docs/ownership.md is the walkthrough for that. When
every list and object is owned, zeno build --no-collector leaves the
collector out of the C file: docs/no-collector.md.
Programs that only use numbers, bools, string literals and enum cases without data do not allocate at all, and the collector never runs.
What differs from zeno run, on purpose
- Very deep recursion. The interpreter stops at 1000 calls with a clear error. The compiled program uses the real stack, so it goes much deeper, and if it runs out, the operating system stops it.
- The error report names the file, line and column but does not quote the source line, because the compiled program does not carry the source.
- A variable that became
noneagain after it was checked (thelimitexample above). Both stop with an error at that line, but the wording differs: the interpreter complains about the operation that was tried, the compiled program about the variable. - In a firmware build, an error the runtime finds stops the program
even inside a
try. Anexceptthat could only be waiting for one is refused when the program is built. Seefirmware.md. sumof an empty list of floats prints0.0compiled and0interpreted. The compiled answer is the right one: the interpreter does not know what an empty list was meant to hold.
Things to try
- Build
examples/primes.zenoand find, inprimes.c, the line each Zeno line turned into. - Write a program where the order of two calls matters, build it, and find the temporaries. Then make the calls not matter and watch them vanish.
- Add
math.tan. You need one line inemit_c.rs(and it should already work in the interpreter and checker; if not, add it there first). - Add
time.sleep(500 ms). What should it compile to on a PC? On a microcontroller? - Compile
examples/primes.zenowith-O0and-O2and time both againstzeno run. Where does the time go in each? - Build
examples/sensor.zenoand find thesetjmpinsensor.c. Which variable inread_allbecamevolatile, and why that one? - In the same file, find the struct that
in_rangepackslowandhighinto. Then find the box thatcounterkeepscountin. - Make a named function inside a function compile. Start with the easy half: one that is only used below the line it is written on.
This page is docs/compiler.md in the language repository.