Building IR in Memory
How a frontend constructs an Esper unit through the builder API, without going through the text format.
This page describes the current SSA builder and backend implementation. Its unversioned esp examples are compatibility snapshots, still accepted by the reader. Start with the new text walkthrough for esper 1 syntax and
the roadmap for the library-first migration boundary.
Every other guide writes .esp text and lets the parser turn it into IR. A real
frontend does not do that. It holds an AST, and it wants to produce the IR
directly. That is what the builder API is for: the parser is one of its clients,
not a layer above it. That is meant literally. src/text/parser.c builds every
function, block, instruction, global, constant and type through the calls on
this page, so a unit the parser produces is one a frontend could have
constructed call for call.
Nothing on this page goes through the text format, and nothing includes a
private header. The whole surface is in esper.h under MARK: In-Memory Builder.
The shape of a build
A build has four moves, in this order:
esper_unit_newcreates the unit that owns everything.esper_build_beginopens a function and hands back anEsperBuilder.- Blocks are created, bound, and filled with instructions.
esper_build_endcloses the function, andesper_builder_freereleases the builder.
The builder is per-function. A unit with three functions is three
begin/end pairs against the same unit. The unit outlives all of them, and
esper_unit_free releases every function built into it.
Errors accumulate rather than aborting. Once the builder records a mistake,
every emitter returns ESP_VAL_NONE and does nothing, so a frontend can emit a
whole function and check once at the end. That is why the first error is the
one worth reading: later ones are usually its consequences.
A worked example
We will build two functions and then run the result three ways: printed as text, executed on the interpreter, and emitted as C.
func add(a: Int32, b: Int32) -> Int32 {
return a + b
}
func main() -> Int32 {
var sum: Int32 = 0
for i in 1..<11 { sum = add(sum, i) }
return sum // 55
}
The straight-line function
add needs one block and three instructions. esper_build_param gives the SSA
id of a parameter, which is just its index, and esper_build_binary covers
every two-operand opcode: arithmetic, bitwise, shift, and comparison.
static void esper_doc_build(EsperUnit *unit) {
EsperTypeRef params[2] = {ESP_TYPE_I32, ESP_TYPE_I32};
EsperTypeRef rets[1] = {ESP_TYPE_I32};
EsperBuilder *b = esper_build_begin(unit, "add", params, 2, rets, 1);
esper_build_bind(b, esper_build_block(b, "entry"));
EsperVal sum = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I32,
esper_build_param(b, 0),
esper_build_param(b, 1));
esper_build_ret(b, &sum, 1);
if (!esper_build_end(b))
fprintf(stderr, "add: %s\n", esper_builder_last_error(b));
esper_builder_free(b);
}func $add(%0 i32, %1 i32) -> i32 {
entry:
%2 = add i32 %0, %1
ret i32 %2
}esper_build_end returns NULL when anything went wrong, and
esper_builder_last_error holds the text of the first mistake. The function
belongs to the unit either way, so there is nothing to free on failure beyond
the builder itself.
The loop, and the values it carries
The loop is the interesting half, because a loop header is entered from a block that has not been written yet. Esper handles this by separating a block’s creation from its filling: every block is created up front, so a branch can name one that does not exist yet. The values the header takes are declared on the header, and each branch into it hands them over, so the back edge is written where the values it carries exist.
static void build_main(EsperUnit *unit) {
EsperTypeRef rets[1] = {ESP_TYPE_I32};
EsperBuilder *b = esper_build_begin(unit, "main", NULL, 0, rets, 1);
// Created up front, so the branches below can name them.
EsperBlockId entry = esper_build_block(b, "entry");
EsperBlockId header = esper_build_block(b, "header");
EsperBlockId body = esper_build_block(b, "body");
EsperBlockId done = esper_build_block(b, "done");
esper_build_bind(b, entry);
EsperVal zero = esper_build_ldi(b, ESP_TYPE_I32, 0);
EsperVal one = esper_build_ldi(b, ESP_TYPE_I32, 1);
EsperVal seed[2] = {zero, one};
esper_build_jmp_args(b, header, seed, 2);
esper_build_bind(b, header);
EsperVal sum = esper_build_block_param(b, ESP_TYPE_I32);
EsperVal i = esper_build_block_param(b, ESP_TYPE_I32);
EsperVal limit = esper_build_ldi(b, ESP_TYPE_I32, 11);
EsperVal test = esper_build_binary(b, ESP_OP_LT_S, ESP_TYPE_I32, i, limit);
esper_build_jmp_if(b, test, body, done);
esper_build_bind(b, body);
EsperVal args[2] = {sum, i};
EsperVal next_sum = esper_build_call(b, ESP_TYPE_I32, "add", args, 2);
EsperVal step = esper_build_ldi(b, ESP_TYPE_I32, 1);
EsperVal next_i = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I32, i, step);
// The back edge, written where the values it carries exist.
EsperVal carry[2] = {next_sum, next_i};
esper_build_jmp_args(b, header, carry, 2);
esper_build_bind(b, done);
esper_build_ret(b, &sum, 1);
if (!esper_build_end(b))
fprintf(stderr, "main: %s\n", esper_builder_last_error(b));
esper_builder_free(b);
}
Two rules govern block parameters. A parameter binds by position from the
block’s first instruction, so every one of them must be declared before
anything else in the block, which is why both come before the ldi. And every
branch into the block carries one argument per parameter, in the order they
were declared; esper_verify reports a branch that carries the wrong number.
esper_build_call looks its callee up by name, so add has to exist by the
time main is built. Building it first is the simplest way; esper_func_declare
is the other, and is what mutual recursion needs.
Driving it
int main(void) {
EsperUnit *unit = esper_unit_new("sum_to_ten");
build_add(unit);
build_main(unit);
esper_print(unit, stdout);
printf("main() = %d\n", esper_exec(unit, "main"));
esper_emit_c(unit, stdout);
esper_unit_free(unit);
return 0;
}
The printed IR is worth reading closely, because it shows the id numbering the next section is about:
unit "sum_to_ten"
func $add(%0 i32, %1 i32) -> i32 {
entry:
%2 = add i32 %0, %1
ret i32 %2
}
func $main() -> i32 {
entry:
%0 = ldi i32 0
%1 = ldi i32 1
jmp header(%0, %1)
header(%3 i32, %4 i32):
%5 = ldi i32 11
%6 = lt.s i32 %4, %5
jmp.if %6 body done
body:
%8 = call i32 $add, %3, %4
%9 = ldi i32 1
%10 = add i32 %4, %9
jmp header(%8, %10)
done:
ret i32 %3
}
esper_exec returns 55. The C the backend emits compiles as C99 and returns 55
as its exit status:
int32_t main(void) {
/* ... */
main_header:;
v5 = (int32_t)11;
v6 = ((int32_t)v4 < (int32_t)v5) ? 1 : 0;
if (v6) {
goto main_body;
} else {
goto main_done;
}
main_body:;
v8 = add(v3, v4);
v9 = (int32_t)1;
v10 = (int32_t)(v4 + v9);
{ int32_t _arg3 = v8; int32_t _arg4 = v10; v3 = _arg3; v4 = _arg4; }
goto main_header;
main_done:;
return v3;
}
The arguments became a pair of temporaries copied in the predecessor, which is
the standard lowering and the reason the temporaries exist: v3 and v4 must
be read before either is overwritten.
A version of this program lives at examples/build.c and runs under
task api-test.
The calls a merge uses
A merge is a block parameter: the block declares what it takes, and each branch into it hands over the arguments. Three calls reach the form:
EsperVal esper_build_block_param(EsperBuilder *b, EsperTypeRef type);
void esper_build_jmp_args(EsperBuilder *b, EsperBlockId target,
const EsperVal *args, uint32_t count);
void esper_build_jmp_if_args(EsperBuilder *b, EsperVal cond,
EsperBlockId if_true, const EsperVal *true_args,
uint32_t true_count, EsperBlockId if_false,
const EsperVal *false_args,
uint32_t false_count);
A conditional branch takes one argument list per arm, since the two arms enter
different blocks and so bind different parameters. With no arguments,
esper_build_jmp_args is esper_build_jmp and esper_build_jmp_if_args is
esper_build_jmp_if.
A diamond needs no back-patching at all, because the value travels on the branch rather than in a list the merge block holds:
EsperBlockId entry = esper_build_block(b, "entry");
EsperBlockId left = esper_build_block(b, "left");
EsperBlockId right = esper_build_block(b, "right");
EsperBlockId merge = esper_build_block(b, "merge");
esper_build_bind(b, entry);
EsperVal lo = esper_build_ldi(b, ESP_TYPE_I32, 20);
EsperVal hi = esper_build_ldi(b, ESP_TYPE_I32, 42);
EsperVal c = esper_build_binary(b, ESP_OP_GT_S, ESP_TYPE_I32, hi, lo);
esper_build_jmp_if(b, c, left, right);
esper_build_bind(b, left);
esper_build_jmp_args(b, merge, &hi, 1);
esper_build_bind(b, right);
esper_build_jmp_args(b, merge, &lo, 1);
esper_build_bind(b, merge);
EsperVal p = esper_build_block_param(b, ESP_TYPE_I32);
esper_build_ret(b, &p, 1);
That prints as the following, and esper_exec returns 42:
func $main() -> i32 {
entry:
%0 = ldi i32 20
%1 = ldi i32 42
%2 = gt.s i32 %1, %0
jmp.if %2 left right
left:
jmp merge(%1)
right:
jmp merge(%0)
merge(%6 i32):
ret i32 %6
}
A parameter is an instruction like any other, so it takes an SSA id by position:
%6, because the two jumps above it own %4 and %5.
Two shapes are refused rather than encoded wrongly. A parameter on the entry
block is an error, since the entry block is entered by calling the function
and its values are the function’s own parameters. So is a parameter emitted
after ordinary code, because arguments bind by position from the block’s
first instruction and a parameter below anything else would bind nothing. Both
report through esper_builder_error_count and esper_builder_last_error.
The argument count is not checked against the target, because the target may
still be unbound when the branch is emitted. esper_verify reports a mismatch
once the function is finished.
Which form to pick is a frontend’s choice, and nothing downstream prefers one: the parser, printer, interpreter, every backend and every optimization pass take either. A pass that has to build a merge of its own builds it in whichever form the function was already written in.
Expressions on the operand stack
Naming every intermediate is the tedious half of a frontend, and it is exactly
the half an expression tree does not need names for. The esper_sb_* calls are a
second way to drive the same builder: each one pops what it needs off an operand
stack, emits the instruction the matching esper_build_* call would, and pushes
what it made. Walking an expression tree becomes a post-order traversal with no
bookkeeping.
// (a + b) * 2, where a and b are the function's parameters.
esper_sb_param(b, 0);
esper_sb_param(b, 1);
esper_sb_binary(b, ESP_OP_ADD, ESP_TYPE_I32);
esper_sb_ldi(b, ESP_TYPE_I32, 2);
esper_sb_binary(b, ESP_OP_MUL, ESP_TYPE_I32);
esper_sb_ret(b, 1);
This is a facade, not a second IR. The instructions that come out are the same
ones, and the stack holds EsperVal, so the two styles mix freely:
esper_sb_push puts a value built through the value API onto the stack, and
esper_sb_pop takes one off to hand to a call that has no stack form.
esper_sb_param(b, 0);
esper_sb_ldi(b, ESP_TYPE_I32, 0);
esper_sb_binary(b, ESP_OP_NE, ESP_TYPE_I32);
esper_build_jmp_if(b, esper_sb_pop(b), body, done);
The first value pushed is the leftmost operand, so an expression is emitted in
the order it is read. esper_sb_binary pops two and pushes one;
esper_sb_select pops the condition, then the true value, then the false value;
esper_sb_store pops an address and then a value and pushes nothing;
esper_sb_call pops argc arguments and pushes the result unless the callee
returns void. esper_sb_drop discards the top, for a statement whose value the
source does not use, and esper_sb_depth reports how many values are held.
| Stack call | Value-API equivalent | Pops | Pushes |
|---|---|---|---|
esper_sb_ldi | esper_build_ldi | 0 | 1 |
esper_sb_ldc | esper_build_ldc | 0 | 1 |
esper_sb_param | esper_build_param | 0 | 1 |
esper_sb_unary | esper_build_unary | 1 | 1 |
esper_sb_binary | esper_build_binary | 2 | 1 |
esper_sb_select | esper_build_select | 3 | 1 |
esper_sb_load | esper_build_load | 1 | 1 |
esper_sb_store | esper_build_store | 2 | 0 |
esper_sb_global_get | esper_build_global_get | 0 | 1 |
esper_sb_global_set | esper_build_global_set | 1 | 0 |
esper_sb_call | esper_build_call | argc | 0 or 1 |
esper_sb_ret | esper_build_ret | count | 0 |
The one rule is that the stack must be empty when a block is bound and when
esper_build_end runs. Both check, and both report build.stack when values are
left behind, along with how many. Popping an empty stack reports the same code
and yields ESP_VAL_NONE.
The rule is deliberate rather than an implementation limit. A value carried across a control-flow edge is what a block parameter is for; letting the stack carry it would mean agreeing on its depth and its types at every join, which is a parameter list with the declaration left out. Keeping the stack inside one block is what makes this a facade over SSA rather than a stack machine with the hard part hidden.
Letting a pass place the merges
The loop above declares its own parameters, which is the direct thing to do when
the frontend already knows where a value’s definitions merge. A frontend that
does not want to know can emit a stack slot per local instead, with a store
for every assignment and a load for every read, and declare no parameters at
all.
There is no dedicated emitter for salloc; it is a one-operand instruction, so
esper_build_unary reaches it:
EsperVal size = esper_build_ldi(b, ESP_TYPE_I32, 4);
EsperVal slot = esper_build_unary(b, ESP_OP_SALLOC, ESP_TYPE_PTR, size);
EsperVal zero = esper_build_ldi(b, ESP_TYPE_I32, 0);
esper_build_store(b, ESP_OP_STORE, ESP_TYPE_I32, slot, zero);
// ... later, in another block
EsperVal cur = esper_build_load(b, ESP_OP_LOAD, ESP_TYPE_I32, slot);
esper_mem2reg then promotes every slot that is only loaded from and stored to
into SSA values, inserting the block parameters the control flow needs:
EsperConfig cfg = {0};
cfg.opt_mem2reg = true;
esper_optimize(unit, &cfg);
It runs first in the pipeline, ahead of the scalar passes, because what it
produces is what they are written to work on. A slot survives as memory when its
address is used as anything other than the address operand of a plain load or
store, when the accesses disagree about the type, or when a load can be reached
before any store has run. Optimization
Passes has the full rule.
Volatile accesses
An address that names a device register rather than memory wants the volatile form, which is a separate emitter rather than a flag argument so no existing call site has to change:
esper_build_store_volatile(b, ESP_OP_STORE, ESP_TYPE_I32, reg, value);
EsperVal status = esper_build_load_volatile(b, ESP_OP_LOAD, ESP_TYPE_I32, reg);
Nothing may delete a volatile access, merge two of them, or move one out of a
loop, and a slot touched by one is never promoted. esper_inst_is_volatile()
reads the flag back off an instruction, which a consumer lowering the IR has to
carry through: dropping it turns a device register access into an ordinary one,
which is the bug the flag exists to prevent. Every backend lowers the flag.
Traps, bulk memory, and bit reversal
A bounds check needs somewhere to branch to, and both terminators take no operands:
esper_build_trap(b); // stop the program
esper_build_unreachable(b); // assert control never arrives here
Use esper_build_trap when the language says execution must not continue, and
esper_build_unreachable when the frontend has already proved the path
impossible. Reaching an unreachable is undefined, which is what lets
control-flow simplification fold away the branch arm that leads to one. Emitting
one on a path that can actually be reached makes the whole function’s behaviour
undefined, not just that path’s.
Copying a struct and clearing a buffer are the two things a frontend would otherwise emit a byte loop for. Each takes three values and counts bytes:
esper_build_memcpy(b, dst, src, len); // regions must not overlap
esper_build_memmove(b, dst, src, len); // regions may overlap
esper_build_memset(b, dst, value, len); // writes value's low byte
bswap and bitrev have no emitter of their own because they are ordinary
unary opcodes, and both reverse within the value’s own width:
EsperVal swapped = esper_build_unary(b, ESP_OP_BSWAP, ESP_TYPE_I32, x);
EsperVal reversed = esper_build_unary(b, ESP_OP_BITREV, ESP_TYPE_I32, x);
Arithmetic that clamps, and arithmetic that reports
Plain add and mul wrap. A language that says otherwise needs one of two
other answers, and each has its own emitter so that a wrong opcode is refused
where it is written rather than where it is executed:
EsperVal clamped = esper_build_sat(b, ESP_OP_ADD_SAT_S, ESP_TYPE_I32, x, y);
EsperVal did_ovf = esper_build_overflow(b, ESP_OP_ADD_OVF_S, ESP_TYPE_I32, x, y);
esper_build_sat takes the four saturating opcodes, add.sat and sub.sat in
a signed and an unsigned form, and yields the clamped result at the named
width. esper_build_overflow takes the six checks, add, sub and mul in
both signednesses, and yields a flag: the named type is the width the check is
made at, and the result is an i32 that is one when the operation would have
run past it. The wrapped value is a separate instruction, so a frontend wanting
both emits the pair on the same operands.
What callers get wrong
Four things account for most of the mistakes, and none of them announce themselves in the type system.
Blocks must be bound in creation order
A block owns a contiguous run of the instruction stream. Binding out of order would ask the builder to interleave two runs, so it refuses:
EsperBlockId entry = esper_build_block(b, "entry");
EsperBlockId tail = esper_build_block(b, "tail");
esper_build_bind(b, tail); // error: build.block_order
esper_build_bind(b, entry);
That reports block 'tail' bound out of order; bind blocks in creation order,
esper_builder_error_count becomes 1, and esper_build_end returns NULL.
Creating blocks up front and binding them in the same order is the whole discipline. It costs nothing, because creating a block does not commit to what goes in it, and a branch may name a block long before it is bound.
A block id is not a pointer
EsperBlockId is a uint32_t index, and deliberately so. The builder grows its
block array as blocks are created, so an EsperBlock * handed out earlier would
dangle after the next esper_build_block. An index stays valid for the whole
build.
The consequence is that block ids are only meaningful to the builder that made
them, and ESP_BLOCK_NONE rather than NULL is the failure value. The
read-side EsperBlock * from esper_func_block is a different thing, obtained
after the function is finished and its storage has stopped moving.
SSA ids are positional
An SSA id is not allocated. It is a position:
id = param_count + inst_index
Parameters occupy 0 .. param_count - 1, and every instruction then takes the
next id in order. esper_func_value_count is param_count + inst_count, and
that identity holds at every point in a build.
This is why an id is stable under an in-place pass and why nothing needs renumbering: the id is the position. It is also why an id from one function means nothing in another.
A void instruction still consumes an id
ret, jmp, jmp.if and store produce no value, and they take an id
anyway, because the id is a position rather than a name.
EsperVal a = esper_build_ldi(b, ESP_TYPE_I32, 7);
esper_build_store(b, ESP_OP_STORE, ESP_TYPE_I32, esper_build_param(b, 0), a);
EsperVal c = esper_build_ldi(b, ESP_TYPE_I32, 9);
In a function with one parameter, a is %1 and c is %3, not %2. The
store owns %2; esper_func_value_type(f, 2) answers ESP_TYPE_VOID, and
esper_inst_id on the store returns 2. The printed form leaves the gap
visible, because a void instruction has no result to print:
func $f(%0 ptr) -> i32 {
entry:
%1 = ldi i32 7
store i32 %0, %1
%3 = ldi i32 9
ret i32 %3
}
The same gaps appear in the loop above: jmp takes %2, which is why the
first parameter is %3, and jmp.if takes %7, which is why the call is
%8.
A frontend that keeps its own map from AST node to EsperVal never has to
think about this, because it holds the ids the emitters returned. A frontend
that tries to predict the next id has to count void instructions, and should
not.
Positions, scopes, and names
A handful of calls exist purely so that what comes out the far end can be debugged. None affects the code that is generated.
esper_build_set_loc sets the source position later instructions carry, and
that a diagnostic from this builder points at. A frontend calls it as it walks
its own tree:
uint32_t file = esper_unit_add_source(unit, "hello.src");
esper_build_set_loc(b, file, 12, 5);
Positions are 1-based, and a line of zero means unknown, which is where a
builder starts. esper_unit_add_source is idempotent on a path already
registered, so a frontend calling it once per function still gets one entry.
esper_build_stamp_loc is for a reader that learns the position afterwards
rather than before, a text parser meeting a trailing annotation being the case
it exists for. It applies to the instruction just emitted, and its
is_explicit argument says whether the position is one the source wrote down
or one worth keeping only for diagnostics. esper_func_set_loc records where a
function was declared, which a declaration has whether or not it has a body.
esper_build_scope_open and esper_build_scope_close bracket a lexical scope,
and esper_build_local records that an SSA value is the frontend’s variable of
a given name. Without them a variable in the debugger is a numbered register.
A scope closed when none is open, or left open at esper_build_end, reports
build.scope.
The debug information a backend writes is only as good as what the frontend recorded here. See Embedding for how those diagnostics reach you.
Constants, globals, and multi-return
esper_build_ldi carries a 32-bit immediate inside the instruction. Anything
wider goes in the unit’s constant table first:
uint32_t k = esper_unit_add_const_i64(unit, 1ull << 40);
EsperVal big = esper_build_ldc(b, ESP_TYPE_I64, k);
esper_unit_add_const_f32 and esper_unit_add_const_f64 are the float
equivalents, and esper_unit_add_const_int, esper_unit_add_const_float and
esper_unit_add_const_v128 cover the narrower types and the vector one. Any of
them returns ESP_NO_CONST when the entry could not be made.
An aggregate constant needs an image rather than a value.
esper_unit_add_const_agg takes one the caller has already laid out;
esper_const_agg_begin computes the offsets instead, so the members are
written in declaration order and the ABI stays out of the frontend:
EsperConstAgg *ca = esper_const_agg_begin(unit, esper_tref_agg(point));
esper_const_agg_int(ca, 3);
esper_const_agg_int(ca, 4);
uint32_t init = esper_const_agg_end(ca);
Globals are added with esper_unit_add_global, which names a constant-table
entry as its initializer, or with esper_unit_add_global_extern for one the
host supplies. They are read and written by index with
esper_build_global_get and esper_build_global_set, and
esper_unit_set_global_loc records where the frontend declared them.
Reading back goes the other way. esper_unit_const_type says which of
esper_unit_const_int, esper_unit_const_float and esper_unit_const_bytes
applies, and a global reports its type, mutability, extern-ness and
initializer, so a backend of your own can emit what a parsed unit holds.
A call to a multi-return function is built with ESP_TYPE_TUPLE as its result
type, and the components are projected out one at a time:
EsperVal pair = esper_build_call(b, ESP_TYPE_TUPLE, "divmod", args, 2);
EsperVal quot = esper_build_extract(b, ESP_TYPE_I32, pair, 0);
EsperVal rem = esper_build_extract(b, ESP_TYPE_I32, pair, 1);
Naming a value before it exists
A block can be branched to before it is filled, because esper_build_block
hands out its id first and only decides its instruction range later. A value has
no such escape: its id is its position in the stream, so nothing can name it
until the instruction defining it exists.
Most frontends never meet this. A value arriving from a path not yet emitted travels as a branch argument, which the branch supplies once that path is emitted. What is left is a name read before its definition is reached at all, which is what a text format permits and a tree walk does not. For that there is a stand-in:
EsperVal later = esper_build_forward(b);
EsperVal sum = esper_build_binary(b, ESP_OP_ADD, ESP_TYPE_I32, x, later);
...
esper_build_forward_define(b, later, real);
The stand-in goes in as an operand anywhere a real value can, and every slot
holding it is rewritten when esper_build_forward_define says what it stood
for. esper_build_end refuses the function if one was never defined, rather
than emitting a dangling operand. esper_val_is_forward tells the two apart.
Storage, syscalls, and vectors
The instructions with no arithmetic shape of their own each have an emitter:
EsperVal mem = esper_build_alloc(b, ESP_OP_HALLOC, size);
esper_build_free(b, mem);
EsperVal ret = esper_build_syscall(b, ESP_TYPE_I64, number, args, 3);
EsperVal v = esper_build_vsplat(b, scalar, ESP_LANE_I32X4);
EsperVal w = esper_build_vbinary(b, ESP_OP_VADD, v, v, ESP_LANE_I32X4);
EsperVal lane = esper_build_vext(b, ESP_TYPE_I32, w, ESP_LANE_I32X4, 0);
esper_build_alloc takes ESP_OP_ALLOC, ESP_OP_SALLOC or ESP_OP_HALLOC
and yields a pointer. esper_build_syscall picks the opcode from the argument
count, so a caller never chooses between the seven spellings.
esper_build_vunary, esper_build_vload and esper_build_vstore cover the
rest of the vector set.
Describing a memory access
A frontend lowering a field access knows more about it than the address does.
EsperMemAccess carries the rest: what the access is aligned to, whether it is
volatile, and which member of which aggregate it reaches.
EsperMemAccess access = {0};
access.align = 4;
access.type_index = point;
access.member_index = 1;
EsperVal y = esper_build_load_at(b, ESP_OP_LOAD, ESP_TYPE_I32, addr, &access);
None of it changes what is computed, and all of it changes what a backend and a
debugger can say. Zero-initialise the struct and fill in only what is known;
type_index of ESP_NO_TYPE means the access is not attributed to a member.
With a zeroed description these are esper_build_load and esper_build_store.
Types that name each other
esper_unit_add_struct and esper_unit_add_array declare a complete type in
one call, which is what a frontend whose types are already ordered wants. Two
types that name each other cannot both be complete when the first is declared,
so the index has to exist before the members do:
uint32_t node = esper_unit_declare_type(unit, "Node", ESP_AGG_STRUCT);
uint32_t list = esper_unit_declare_type(unit, "List", ESP_AGG_STRUCT);
esper_unit_type_add_member(unit, node, "next", esper_tref_agg(node));
esper_unit_type_add_member(unit, list, "head", esper_tref_agg(node));
esper_type_layout(unit);
Nothing is laid out until esper_type_layout runs, which is the price of
filling a type in over several calls. esper_unit_type_set_element says what an
array or an alias is made of, and the esper_type_* queries read the table
back: its kind, its members’ names and types, an array’s length.
After the build
A finished unit is a finished unit, whatever produced it. The optimizer, the backends and the interpreter cannot tell a built unit from a parsed one, so everything in Embedding applies unchanged:
EsperConfig cfg = {0};
cfg.opt_const_fold = true;
cfg.opt_dce = true;
esper_optimize(unit, &cfg);
cfg.backend = ESP_BACKEND_C;
esper_codegen(unit, &cfg, stdout);
Note that cfg.backend takes an EsperBackend, and cfg.target an
EsperTarget. The two are separate on purpose: the backend is fixed when Esper
is built, and the target is chosen per invocation, which is what lets one
binary cross-compile. Build configuration covers which
backends a given build contains.