sysl

Memory

The three modes, ARC, weak references, places and ref, escape analysis, the raw tier, and device memory.

Every value in a sysl program lives in one of three ways, and the declaration that names it is what says which. This page is the whole of that model: what each mode costs, what it guarantees, how a reference is made, when storage moves without being asked, and where the guarantees stop.

If you do not write *T, you cannot segfault. The safe subset — values, references, weak references, arrays, and slices — has no use-after-free, no null dereference, no out-of-bounds access, and no dangling pointer. The single unsafe primitive is the raw pointer, and it is greppable: a program’s exposure to C’s hazards is exactly the lines with a * in the type. A driver has them and an application does not, and you can tell which by looking.

The three modes

modewhat it iswho frees itneeds an allocator
Ta value — on the stack, in a register, or inline in something biggernobody; it goes when its frame or its container doesno
&Ta counted reference to a heap objectthe compiler, when the last reference goesto make one
*Ta raw machine pointeryouno
struct Point
    x: int
    y: int
end Point

var here = Point(1, 2)
var shared: &Point = Point(3, 4)
var raw: *Point = &here

print(here.x, shared.x, raw.x)
1 3 1

The choice is per declaration, not per type. The same struct Point is a value in the first line, a heap object in the second, and a pointer’s target in the third. This is C’s arrangement rather than Swift’s or Scala’s, where a type is struct or class once and for all, and systems code depends on it: a page-table entry, a temporary in a loop, and a shared configuration object may all be the same type held three ways.

Value is the unmarked default, and the kernel is why. A module that has given up the allocator cannot make a reference. If the reference were the bare default, the unmarked spelling would be the one that is illegal exactly where value semantics matter most — every kernel struct would need a mark, and would hit “references need an allocator” on its most natural form. With value as the default, T is usable in both worlds, *T covers pointers, and &T appears in allocator-free code only where something else handed one over.

T — values

A value lives inline: in a stack slot, in a register, or embedded in the struct or array that contains it. Assignment and argument passing copy it, exactly as in C.

struct Counter
    n: int
end Counter

var a = Counter(1)
var b = a

b.n = 99

print(a.n, b.n)
1 99

There is no move semantics and no use-after-move. a is still a whole Counter after b = a, and it always will be — the concept of a value that has been given away does not exist here, so neither does the class of error built on it.

A value containing a &T field is still a value: copying it retains that field, so the copy is independently safe and the original is untouched. That is the one thing a copy does beyond moving bytes, and it is what makes “values are simple” true rather than approximately true.

Values need no allocator and are always safe.

&T — counted references

A &T is a reference to a heap object managed by ARC: the compiler emits the retains and releases, and the object is destroyed the moment the last strong reference to it goes away. It is:

  • shared and freely aliased — many references may point at one object;
  • mutable through any alias — there is no borrow checker and no exclusivity rule;
  • non-null — a &T always points at a live object;
  • automatically managed — no free, and no place to forget one.
struct Counter
    n: int
end Counter

var a: &Counter = Counter(1)
var b = a

b.n = 99

print(a.n, b.n, a == b)
99 99 true

Compare that with the value example above: the same two lines, and the declared mode is the only difference. b = a copied a Counter there and made a second reference here.

Two live mutable aliases is what Rust exists to prevent, and permitting them is the deliberate trade. Reference counting pays for it at run time — a retain here, a release there — and buys a language with no lifetimes, no borrow checker, and nothing to prove to a checker as you write.

References compare by address. == and != on a reference or a pointer ask whether the two name the same object, which is the only question a bare address can answer, and there is no ordering on either.

There is no allocation keyword

A reference is made by writing an ordinary construction where a &T is expected. The expectation is what puts the object on the heap:

struct Process
    id: int
    priority: int
end Process

spawn(id: int) -> &Process = Process(id, 0)

var p = spawn(7)
var q: &Process = Process(8, 1)
var v = Process(9, 2)

print(p.id, q.id, v.id)
7 8 9

Process(id, 0) in the body allocates because the return type says &Process. Process(8, 1) allocates because the annotation says so. Process(9, 2) does not, because nothing asked — with no expectation at all, a construction is a value.

No new, no malloc, no Rc::new, no .clone(), and no wrapper type. The positions that fix the expectation are the ones that already state a type for generic inference:

positionexample
a declared localvar p: &Point = Point(1, 2)
a parameterf(p: &Point), called as f(Point(1, 2))
a return type-> &Point, with a construction as the result
a struct fieldItem("a", Node(1)) where the field is &Node
an enum variant’s payloadSome(Node(1)) where the payload is &Node

An element of an array or slice, a part of a tuple, a generic argument, and a parameter of a callable type all ask the same way. The rule is about the types and not about the syntax: a T written where a &T is expected goes on the heap, whatever produced it.

A prefix & on a construction was considered as an explicit mark and not taken — it would collide with address-of, which is a different operation producing a different type.

The expectation reaches each branch

An if, a match, and a loop yield their value through their branches, so a &T expectation reaches each branch on its own rather than the expression as a whole:

struct Point
    x: int
    y: int
end Point

var origin: &Point = Point(0, 0)
var far = true
var p: &Point = if far then Point(9, 9) else origin

print(p.x, p.y)
9 9

That is what lets a value branch and an already-reference branch meet at &Point. Boxing the whole expression instead would fail, because a branch that is already a &Point cannot un-become a value — something that is already a reference passes through untouched.

It is also what lets a scalar be referenced without int needing a constructor of its own:

var n: &int = 41

print(*n + 1)
42

A reference is never null

There is no null in the safe subset and no way to make one, so there is no null dereference to diagnose. Something that may be absent is an Option[&T], and getting at it is a match:

struct Item
    label: string
end Item

show(o: Option[&Item]) -> string
    o match
        Some(i) -> i.label
        None    -> "(none)"

var it: &Item = Item("a")

print(show(Some(it)), show(None))
a (none)

The match is not ceremony around a null check. It is the only route to the item, so the absent case cannot be forgotten rather than merely being unwise to forget.

What a heap object costs

Every ARC object carries three header words: the strong count, a pointer to the function that destroys it, and a weak count. Release decrements the strong count; at zero it calls through the hook, which releases whatever the payload holds and returns the storage to the heap the object came from — one heap for the whole program, libc’s unless a package named another pair.

Putting the destructor behind a hook rather than inline at each release site is what makes letting go of a reference type-erased — one instruction sequence, no static type. Slices need exactly that, since a []T gives no clue what type of object its owner word points at.

One word per object buys three things:

  • ARC works the same everywhere. A module that never allocates can still retain and release, because the free path calls back into the heap the object came from. The operations are a few instructions and depend on no runtime.
  • Several heaps coexist. A kernel heap, a server’s heap, and an arena are different allocators, and an object frees itself into the one that made it, wherever it is dropped.
  • There is no boundary rule to learn. Ownership crosses an allocator-free edge like any other value.

Teardown is iterative, so depth is bounded. Destroying the head of a long chain of references would recurse one frame per node if a destructor called the next destructor. It does not: an object whose count reaches zero is pushed onto a worklist, reusing its now-dead refcount slot as the link, and the first release to hit zero drains the list in a loop. A structure of any depth comes apart in O(1) stack. The worklist is per thread, because it is scratch space a drain uses rather than state anything shares.

On a freestanding target the worklist is asked for rather than assumed, since there is no thread pointer to key it on. A &sync release that reaches zero fetches it through one weak symbol, __sysl_arc_reaper, which answers with a head pointer and a draining flag belonging to the running task:

struct sysl_arc_reaper { void *head; unsigned char draining; };

/* FreeRTOS, say: whatever this runtime keeps per task. The storage is the port's to give out and
   must last as long as the task — set when the task is created, never shared with another:

       static struct sysl_arc_reaper slots[MAX_TASKS];
       vTaskSetThreadLocalStoragePointer(task, 0, &slots[n]);            */
struct sysl_arc_reaper *__sysl_arc_reaper(void) {
    return pvTaskGetThreadLocalStoragePointer(NULL, 0);
}

Define nothing and the program uses the single slot it carries, which is right for a machine where nothing schedules — the overwhelming case, and why the symbol is weak. Define it in an RTOS port and &sync T means on that target what it means everywhere else. This is the arrangement &sync‘s counts are already under there: with no ldrex/strex beneath it an atomic increment becomes a call to an __atomic_* the board defines, and on a two-core part that needs a hardware spinlock.

The header is the same three words for every object whether or not anything weakly references it, which is what keeps the type-erased release path expressible at all. The cost is eight bytes on an allocation that already cost a malloc, and it lands only where the feature is used: values, fixed arrays, and *T buffers have no header, so allocator-free code pays nothing for a mechanism it never touches.

weak T — breaking cycles

Reference counting has one honest weakness: a cycle of strong references keeps itself alive. A parent holding its children while each child holds its parent is the shape that does it, and it is common enough in systems code — a back-link in an intrusive list, a process’s pointer to its parent — to have a purpose-built answer.

A weak T does not keep its referent alive. When the last strong reference goes, the object is destroyed and every weak reference to it becomes empty.

struct Node
    label: string
    parent: weak Node
end Node

parent_of(n: &Node) -> string
    n.parent.get() match
        Some(p) -> p.label
        None    -> "(none)"

var root: &Node = Node("root", None)
var kid: &Node = Node("kid", root)

print(parent_of(root), parent_of(kid))
(none) root

weak T is a type, not an operator. There is nothing that makes one; a &T becomes a weak T wherever a weak T is what the context asked for — the struct field above, an argument, a declared local, a returned value. That is the same rule that makes a &T out of a T, so both & and weak live at the boundaries a program annotates and neither appears in a body.

The conversion goes one way. A weak T is not a &T and never silently becomes one, because becoming one is the operation that can fail.

The & is in the mode already, so weak &T is the word said twice — and it is refused rather than read as a weak edge to a box holding a reference, which is not a thing sysl has:

struct Node
    v: int
end Node

var r: &Node = Node(1)
var w: weak &Node = r
'weak Node' is already a weak edge to a counted Node, so the '&' says the mode a second time — write 'weak Node'

Reading one is a question, and it is get(). The answer is Option[&T]: a live strong reference with a count taken for the caller, or None. Nothing else may be done to a weak reference — no field selection, no method call, no ==:

struct Node
    label: string
    parent: weak Node
end Node

var root: &Node = Node("root", None)
var kid: &Node = Node("kid", root)

print(kid.parent.label)
may be gone, so nothing is read off one directly

Every road to the object goes through the Option, which is what makes “a weak reference never dangles” a fact about the language rather than a promise about the programmer.

The parentheses are not decoration. .len is a property — a fact about the value, the same answer every time it is asked — while get() is a question about the world: two calls a moment apart may disagree, and the answer costs a count. sysl puts the parentheses on the second kind.

An empty weak reference is written None, and it is the same state whether the object is gone or there never was one. This None is not an Option — it is the empty value of the weak reference itself, chosen because it is exactly what get() will hand back for it.

It is also the zero value of weak T, which is the one place weak T parts company with &T: there is no such thing as a reference to nothing, so &T has no zero. A struct with a weak field therefore still has one, and an uninitialized declaration of it is still a declaration:

struct Node
    label: string
    parent: weak Node
end Node

var n: Node

n.parent.get() match
    Some(p) -> print("held")
    None    -> print("empty")
empty

A weak reference may not be made from a value with nowhere else to live. Constructing into a weak position would box the value and then weaken it, leaving the weak edge as the object’s only holder — and the object dead before the statement ended:

struct Node
    label: string
    parent: weak Node
end Node

var kid: &Node = Node("kid", Node("root", None))

print(kid.label)
a weak reference does not keep Node alive, and nothing else here holds this one

The only default a weak T parameter can have is None, and that falls out rather than being decided. A default is produced afresh at each call that omits it, in a scope holding none of the caller’s locals, so what it names would have to outlive every frame — and every candidate that names an object is closed off. A construction is refused by the rule above; a top-level var is a local of the entry point; and a module-level val counts nothing, which a reference does. What is left is the one value that holds nothing:

struct Node
    label: string
    parent: weak Node
end Node

adopt(child: string, parent: weak Node = None) -> string
    parent.get() match
        Some(p) -> child + " of " + p.label
        None    -> child + " of nobody"

var root: &Node = Node("root", None)

print(adopt("a"), adopt("b", root))
a of nobody b of root

weak sync T is refused, naming the concurrency chapter. Upgrading an atomic weak reference is a compare-and-swap loop against a count another thread may be driving to zero underneath it, and that is written when there is something to race with.

Everything a &T may point at, a weak T may be taken of: a struct, a scalar, an array, a generic instantiation, a trait object, and a type parameter.

*T — the raw pointer

*T is a bare machine pointer, exactly like C’s: no length, no count, no checks, manual lifetime. It is the only unsafe primitive over data, it needs no runtime, and it is how a kernel, a driver, or an allocator’s own internals are written.

bump(n: *int)
    *n += 1

var counter = 41

bump(&counter)

print(counter)
42

Anything C can do through a pointer, sysl can. The pointer is where the language’s guarantees stop, so it carries C’s whole surface rather than a safer subset of it — a subset would only mean a kernel that could not be written here.

null exists, and only here

null is the absent raw pointer. It has no type of its own and takes the *T its context expects, the way a bare None takes its type argument:

struct Node
    value: int
    next: *Node
end Node

var third = Node(3, null)
var second = Node(2, &third)
var first = Node(1, &second)

var walk = &first
var total = 0

while walk != null
    total += walk.value
    walk = walk.next

print(total)
6

That program is also the answer to “how does a type reach itself” — see recursive types below.

A generic parameter is a context like any other, and it answers late. A callee still being solved has no type at that position yet, so an argument with none of its own is set aside until the ones that have have been read, and is then taken against the parameter it turned out to stand at:

two[T](a: *T, b: *T) -> bool = a == b

var x: int = 3

print(two(&x, null), two(null, &x))
false false

Either order answers alike, because waiting is not queueing. Setting an argument aside cannot lose the solution — one with no type of its own has nothing to contribute to it — so the parameter is settled by the other arguments or by nothing at all. Nothing at all is refused rather than defaulted: a call with no other argument to read is asked what the pointer points at.

one[T](a: *T) -> bool = true

print(one(null))
'null' takes its type from its context, and there is none here

Arithmetic: two directions, one spelling each

var buf: [8]int = [10, 20, 30, 40, 50, 60, 70, 80]
var base = &buf[0]
var third = &buf[2]

print(*third, third - base, base - third, *(&base[3]))
30 2 -2 40

p - q between two pointers of the same pointee is an isize, and it counts elements, not bytes — C’s ptrdiff_t. It is signed because the order of the operands is the programmer’s, and it is the exact inverse of &p[n]: indexing takes an address and a count to an address, and the difference takes two addresses back to a count, both striding by the pointee.

It is here because the interior-pointer half of libc needs it. memchr, strchr, strrchr, strstr and memmem all hand back a pointer into a buffer the caller owns, and without a difference every one of them is callable and useless, because nothing could turn the answer into an index.

Offsetting stays &p[n]. p + n is not spelled, deliberately: indexing already exists and already strides by the pointee, and a second spelling for one address would be a second thing to keep in step. p + q names no address and is refused too.

Two pointers of different pointee types have no shared element to count, and are refused by the ordinary matching-types rule rather than by one of this operator’s own:

var n = 1
var c = 'x'
var pn = &n
var pc = &c

print(pn - pc)
'-' needs matching types, got *int and *char

A counted &T has no arithmetic at all, keeping the equality it always had and nothing more:

struct Point
    x: int
end Point

var a: &Point = Point(1)
var b: &Point = Point(2)

print(a - b)
operator '-' is not defined for &Point

Arithmetic is a property of the unsafe mode, not of holding an address.

Whether two pointers into unrelated objects may be subtracted is the programmer’s business, as p[i] past the end already is.

Bounds safety follows length, not pointers

The out-of-bounds hazard is governed by whether a value carries its length, independently of where it was allocated:

  • arrays and slices carry their length, so indexing is bounds-checked in every context — hosted or allocator-free, over static, stack, or heap memory;
  • *T carries no length, so it is the one unchecked primitive. p[i] reads the ith element and p[0..<n] views n of them, both exactly as C does and both unchecked, because there is no length to check against and supplying one is the programmer’s assertion.

So even low-level, allocator-free code stays bounds-safe by choosing slices, and reaches for *T where it must — an MMIO window, a page table, a buffer a C function filled. That choice is the point.

Places

A place is something with an address: a local or parameter, a dereference, an element, and a field of any of them. Everything else — a call result, an arithmetic result, a freshly built struct — is a value with no address to take.

operationwhat it does
&placeyields a *T — C’s address-of, with C’s result
*preads through a *T or a &T, and is itself a place
p.fselects, dereferencing one level automatically on both *T and &T

& on a place yields a raw pointer, never a reference. A place lives in a frame or inside another object, so there is no count to take a share of. Reaching a &T means being handed one or constructing one, and taking the address of a local is therefore inherently in the unsafe tier — which is right, because it can dangle and nothing promotes it.

Selection is the only implicit dereference. p.x is (*p).x and p.x = 9 writes through the pointer — Go’s rule, with no ->. Matching a reference to an enum against its variants is match *e.

The shorthand stops at one level, so reaching through a **T is written:

struct Point
    x: int
    y: int
end Point

var p = Point(1, 2)
var pp = &p
var ppp = &pp

print(pp.x, (*ppp).x)
1 1

Leaving the step out is a diagnostic rather than a second implicit dereference, and it says how many levels are left:

struct Point
    x: int
    y: int
end Point

var p = Point(1, 2)
var pp = &p
var ppp = &pp

print(ppp.x)
selection reaches through one level of indirection and **Point has more

Assignment, compound assignment, and ++/-- all take a place, so the same three forms work on a variable, on a field, and through a pointer with nothing special said about any of them.

An element carries one wrinkle the other three do not. A slice’s elements and a pointer’s live wherever the storage is, which is somewhere the expression naming them is not, so they have an address whether or not the expression naming them does. That is what makes rows(g)[i] = v write through to the grid rather than into the view the call handed back. An array’s elements are the array, so they are places exactly when the array is. A string’s bytes are never one: writing a byte of UTF-8 is how a string stops being UTF-8, and that is refused as immutability rather than as the absence of an address.

The hazard on the other side of that rule is the one dangle a single statement can produce. If a temporary view is the only holder of its buffer, the buffer is released at the end of the statement, and &f()[i] is a pointer to freed storage before the next line runs. That is the unsafe tier behaving as advertised — & yields a raw pointer, a raw pointer can dangle, nothing promotes it — and it is called out because every other dangle needs the pointer to be carried somewhere first.

ref — a name for a place

A place can be deep, and until ref the two ways to shorten one both cost something. var t = self.tasks[i] binds a copy, so every read and every write walks the path from the table again; &self.tasks[i] gives the name back and gives up bounds checking, within checking, invariant re-checking, and the guarantee at the top of this page, all in one step. That is a cliff rather than a gradient.

ref binds a name to a place.

struct Task
    state: int
    prio: int
end Task

struct Kernel
    tasks: [4]Task

    advance(*self, i: usize)
        ref t = self.tasks[i]

        t.state = 2
        t.prio = t.prio + 1
end Kernel

var k = Kernel([Task(0, 7); 4])

k.advance(2usize)

print(k.tasks[2].state, k.tasks[2].prio, k.tasks[0].prio)
2 8 7

The place is evaluated once, where the binding is written — the index is computed once, the bounds are checked once — and what the name means afterwards is the storage that was found, not the expression that found it. A later i += 1 leaves t naming the element it always named. Scala’s by-name parameter is the thing this is not: x: => T re-evaluates at every use, which would save no check and would silently make t follow a later change to i.

It is a declaration, never a type

This is the whole of why it can exist in a language with no borrow checker. ref may be written only as a local declaration. There is no ref type, so a ref cannot be a field, a parameter, a return type, an element, or a type argument; it cannot be re-pointed at a second place once bound; it cannot be captured by a closure; and it does not outlive the block that declares it.

A ref of a ref is not a re-pointing and is ordinary — one more name for the place the first one stands for, with the walks following through it:

var xs: [3]int = [1, 2, 3]

ref a = xs[1]
ref b = a

b = 42

print(xs[0], xs[1], xs[2])
1 42 3

That restriction is what keeps the compiler’s knowledge complete. A *T is a type, so the moment one exists it can be carried somewhere the compiler has lost the path it came from. A ref never travels, so the analyzer still holds the place expression, at the point it was written, in the same body.

At run time a ref stores an address. At compile time it remembers a place. Both halves are load-bearing. The address is what makes sixty-five path walks one. The remembered place is what keeps every check a *T would have severed:

  • a write through the ref re-runs the invariant clauses of every struct the place lies inside, found by the same outward walk, because the walk still has the whole place to walk;
  • a ref into a val, or into an element of one, is read-only, since reaching into read-only storage keeps the property;
  • a ref to a within-constrained slot is checked on assignment exactly as the slot is;
  • and the bounds check a subscript owes is paid once, at the binding, rather than not at all.

A ref is therefore not a fourth memory mode. It introduces no representation, no new type, and nothing that can be stored: it is a second way to say a place the three modes already describe.

What may be written

A ref’s initializer must be a place, and a call result has no address for the name to mean:

make() -> int = 7

demo()
    ref x = make()

    print(x)

demo()
'ref' names a place — a local, a field, an element, or a dereference — and this expression has no address for 'x' to name

A ref inherits the place’s writability and gets no modifier of its own. A ref into a val may be read and not written, exactly as the val may. Stating it twice would only create the chance to state it wrong.

A user type’s subscript is not a place. b[i] on a container goes through the Index trait, which is a call rather than a walk to an address, so there is nothing there for a name to mean. A ref reaches the built-in subscript, which is the one that indexes storage.

The one rule: what may move underneath it

A stored address is only as good as the storage staying where it is. C# has ref locals and needs no rule like this, because a tracing collector keeps the old array alive when the variable is pointed at a new one. sysl has no collector in this tier, so the same program would dangle:

struct Table
    cell: []int

    swap(*self)
        ref e = self.cell[0]

        self.cell = [0; 8]

        print(e)
end Table

var t = Table([1, 2, 3])

t.swap()
'e' is a 'ref' standing on storage this assignment would release, so the name would be left pointing at freed memory

While a ref is live, no step of its place that could come to name different storage may be assigned, and no mutating method may be called on a prefix of one. The check is local, decided from types alone, and never asks where the ref is used — which is the question that would need lifetimes.

Which steps are hazards falls out of the model rather than being a list, and the discriminator is ownership, not indirection. Only a step that releases something when it is overwritten can strand a ref:

stephazard?why
a &Tyesoverwriting it drops what it held, and that release may be the last
a view ([]T)yesthe same — a view owns its buffer
a *Tnoa raw pointer owns nothing, so p = q frees nothing
a field, a fixed array, or an element of onenothat storage is the enclosing object’s bytes

Two words carry the rule, and between them they keep the set small. A step is held still when it is an owning step that the path goes through — so the place itself is never in the set, because an assignment to it is what writing through the ref is. ref r = h.node names the slot, so r = other and h.node = other are one statement written two ways and neither is refused, while h = other on a &Holder is.

The *T exclusion is not a concession. A program that reaches its tables through a *Self receiver has no owning step anywhere in its chains, so it is asked for nothing at all — which is exactly the advance above, where the rule costs the program nothing.

The mutating-call half is the conservative one, deliberately. A *self method may write any part of its receiver, so a live ref into that receiver is refused the call whether or not that particular method reassigns anything:

struct Table
    cell: []int

    grow(*self)
        self.cell = [0; 8]
end Table

var t = Table([1, 2, 3])

ref e = t.cell[0]

t.grow()

e = 5
'e' is a 'ref' standing on storage this call could release, since a '*self' method may write its receiver

Deciding otherwise would mean reading the callee’s body, and a module compiles against its imports’ signatures and never their bodies. The cost is one refusal in a program that could have written the ref one line later.

What this does not do is make a *T safe, and it does not try. A ref’s place may be rooted at a pointer, and whether that points anywhere is the raw tier’s ordinary bargain. What the rule buys is that the storage a ref names cannot be released by the block that named it.

A ref to a slot that holds a reference

ref r = self.node, where node is a &Node, names the slot, not the object in it. So the binding takes no count — nothing new holds the object — and r = other is the assignment self.node = other by another name, releasing what was there and retaining what arrives, in that order. Reading r produces the reference and takes a count for the reader exactly as reading the field would.

The distinction matters because the other reading is available and wrong: if binding retained, a ref would be a &T with extra steps, and the count it took would keep an object alive past the write that replaced it.

Where it comes from

The form is old and lives outside this language’s usual references, none of which have it. Ada’s renames is the general case and evaluates the name once at the declaration; Fortran’s ASSOCIATE is the closest in shape, being block-scoped and deliberately not a type; C#’s ref locals are the closest in spelling. C# also shows what the restriction is worth: having added ref returns and ref fields, it spent several releases building the escape analysis that keeping the form local avoids entirely.

Recursive types

A type may reach itself through an indirection, and only through one. next: *Node is pointer-sized and legal, as is next: Option[&Node]; a struct holding itself by value has no finite size:

struct Node
    value: int
    next: Node
end Node

print(1)
type 'Node' contains itself, so it has no finite size

The rule is per cycle rather than per field: a cycle is legal as soon as one edge on it is a *T or a &T, so mutually recursive types work as long as the loop passes through a pointer or a reference somewhere.

Slices keep their backing alive

A slice is three words, not two:

[]T = { owner: *Buf, ptr: *T, len: usize }        // 24 bytes on a 64-bit target

ptr and len name the range; owner keeps the bytes alive. A bare {ptr, len} view can outlive the buffer it views — Go gets away with it because its collector finds the object from an interior pointer, and sysl has no collector — so the slice carries the owning reference itself. Taking a slice retains, dropping one releases, slicing stays O(1) and allocation-free, and “a slice never dangles” becomes true rather than aspirational.

tail() -> []int
    var xs: []int = [1, 2, 3, 4]

    xs[2..]

var t = tail()

print(t.len, t[0], t[1])
2 3 4

The buffer xs named is gone as a name before print runs, and the slice is still valid, because the slice is one of its owners.

owner is null when there is nothing to keep alive — a slice of static data, of a *T buffer, or of a fixed array whose storage outlives every view of it. Retain and release on such a slice are no-ops, so allocator-free code slicing a static or stack buffer pays nothing at all. This is the same immortality rule string literals use, generalized.

A string is then exactly an immutable, validated []u8, and the two share one representation and one implementation.

Escape analysis

The owner word answers “who keeps this alive” for every slice except one: a slice of a local fixed array. That storage is a stack slot, valid until the frame returns and no longer.

slice ofownersafe to keep?
a heap bufferthe bufferyes — retained
static data, a string literalnull (immortal)yes — never freed
a *T regionnullthe programmer’s problem, like every *T
a local fixed arraynullonly while the frame lives

The compiler finds the slices for which that matters, and the programmer writes nothing. There is no @escaping, no lifetime parameter, and no “this result views that argument” marker anywhere in a signature. Go is the precedent; Rust’s lifetimes and Swift’s @escaping are the alternative, and both charge the programmer for what a compiler can work out.

What escapes

A slice whose origin is a local array escapes if it can be reached after the frame returns — that is, if it is:

  1. returned, directly or nested inside whatever carries it back: a struct, an enum, an Option, a tuple, or one slot of a multi-result list;
  2. stored into anything that outlives the frame — a global, a field reached through a &T, or an aggregate that is itself stored somewhere that outlives the frame;
  3. passed as an argument the callee keeps;
  4. captured by a closure that itself escapes;
  5. assigned into a local that escapes — the rule is transitive, resolved as a fixpoint over the function’s own locals.

Everything else needs no allocation: indexing, iterating, sub-slicing into a local that stays local, comparing, and passing to a callee that only reads.

Crossing a call

Two facts about a function are enough to keep the caller’s analysis local and exact, and both are inferred from the body rather than written:

  • per parameter — does the callee let this argument outlive the call?
  • for the result — which parameters may the returned value be a view of?

They are computed bottom-up over the call graph and recorded in module metadata. Recursion starts optimistic and iterates to a fixpoint, so a self- or mutually-recursive function converges on the truth rather than on the conservative answer.

Two cases get the pessimistic answer instead — every parameter kept, the result viewing everything:

  • a call through a trait object, because which body it reaches is a word read at run time, so there is no one summary to consult. Reading through a concrete type or through a bounded type parameter is unaffected, since monomorphization turns the second into a direct call too;
  • a function whose body is not available — an extern, which is the declaration form for exactly that. The foreign side may retain what it was given, and nothing here can tell.

What happens when a slice escapes

With an allocator, the array is promoted. It is allocated as an ARC buffer instead of a stack slot, the slice’s owner points at it, and nothing else about the program changes:

render() -> []u8
    var line: [8]u8

    line[0] = 104u8
    line[1] = 105u8

    line[0..<2]

var l = render()

print(l.len, l[0], l[1])
2 104 105

The array keeps its type, and only its storage moves. A promoted [8]u8 is still a [8]u8: its length is still a compile-time constant, it is still a value that copies on assignment, and every index and store is emitted exactly as an unpromoted one’s is. What changes is where the name’s address points, and that a view of it carries that buffer as its owner. Rewriting the declaration into a []u8 would have been the other way to do it and is wrong — it changes the type at every use, and an array is a value type where a view is not.

Which array moves is decided by the root of the escaping view. An index step is walked through, because an element of a local array of arrays is part of that array’s storage; a field step is not, because that storage belongs to a struct. Only arrays that are both sliced and escaped are promoted.

Two roots have nowhere to be promoted to, and are diagnostics rather than promotions — an array a caller passed by value, which is the caller’s layout, and an array that is a field of a struct on the frame:

first_two(a: [4]int) -> []int = a[0..<2]

print(first_two([1, 2, 3, 4]).len)
a slice of an array this frame owns is returned, so it would outlive the array, and the storage is not this body's to move — it is a field of a value, or an array a caller passed by value. Declare it as a '[]T', which makes a buffer of its own and owns it, or as a '&[N]T' where the length is fixed

Without an allocator it is always a compile error. Under no alloc there is nothing to promote into, so every promotion becomes a refusal reported at the view that leaves the frame. That is how every other allocation-gated feature behaves in the allocator-free subset — growable arrays, escaping closures, and &T creation are all compile errors there — so it introduces no new rule.

Promotion is silent, not hidden

Silent promotion earns the obvious objection: an allocation appears that nothing in the source asked for. The answer is discoverability rather than ceremony — --explain-escapes reports every promotion the compiler made and the route that forced it:

$ sysl build --explain-escapes tty.sysl
tty.sysl:31:12: 'buf' is promoted to the heap, because this view of it is returned

One line per array, in source order, on stderr, accepted by every subcommand. The position is the view that forced the move rather than the declaration, because that is the half a reader cannot work out for themselves. This is Go’s -m, and it is the right shape: the common case costs no reading, and “why did this allocate?” always has an answer. A program that must not allocate says so with no alloc, and then the compiler enforces it rather than reporting it.

The idiom worth reaching for first, even where an allocator exists, is the last one: return a count and let the caller slice its own buffer. That is what snprintf does, what Rust’s buffer writers do, and what most kernel code wants.

Reinterpreting storage

An allocator carves bytes and hands back a typed pointer — that is the whole of what an allocator does. A driver takes an address the datasheet gives as a number and reaches the register block at it. Both need a way to say which type some bytes are, and both are cases the raw tier already committed to.

Three directions, two spellings, because they are not equally dangerous:

writtendirectiontier
usize(p)a pointer as a numberan ordinary conversion
ptr_cast(n)a number as a pointerunsafe
ptr_cast(p)one pointee type as anotherunsafe

A pointer becomes an integer through the ordinary conversion syntax, because it is an ordinary conversion: usize is wide enough to hold any address by definition, so it is total and loses nothing, and the result is a number that cannot be dereferenced. isize takes one too, which is what a program comparing addresses against a signed offset wants.

struct Node
    value: int
end Node

var arena: [64]u8 = [0u8; 64]
var n: *Node = ptr_cast(&arena[0])

n.value = 42

print(n.value, sizeof(Node), usize(n) == usize(&arena[0]))
42 4 true

The target type comes from whatever receives the result — the same way va_arg, a bare None, and a bare null all take theirs. Where that is not where a reader wants to say it, it may be written on the form: ptr_cast[*Node](&arena[0]) is the written type-argument list at a special form. Where neither says which pointer is wanted, the program is told to annotate what receives it:

var arena: [64]u8 = [0u8; 64]
var p = ptr_cast(&arena[0])

print(1)
'ptr_cast' reads an address as a pointer to some type, and nothing here says which

ptr_cast never produces a &T. A reference is a safe-tier value — non-null, refcounted, and relied on by everything the safe subset promises — and an address invented from bytes has no count for ARC to own and no object to be non-null about:

struct Node
    value: int
end Node

var arena: [64]u8 = [0u8; 64]
var n: &Node = ptr_cast(&arena[0])

print(n.value)
'ptr_cast' never produces a reference: a '&T' is counted and non-null, and an address read out of bytes carries no count for anything to own — read it as a '*T'

A weak T is refused for the same reason, and the fat types — a slice, a string — for that reason and one more: they are wider than an address, so there is nothing to reinterpret. What comes out is a *T, and reaching anything else from it is the ordinary route through *p.

sizeof, alignof and offsetof, which measure what is being carved, are on the expressions page.

Device memory

ptr_cast gets a driver to the register block. It does not get it a correct driver, and the missing half is this: an optimizer is entitled to assume that reading the same storage twice yields the same value, that a store nobody reads is a store nobody needs, and that two accesses in a row may be one wider access. Every one of those assumptions is false at a device. while regs.status == 0u32 do () is a loop that reads a register until the hardware changes it, and a compiler that hoisted the read out would spin forever on the first value it saw.

So sysl has C’s qualifier, spelled the way C spells it — in the type:

struct Uart
    status: volatile u32
    data:   volatile u32
    baud:   u32
end Uart

var block = Uart(1, 0, 115200)
var regs: *Uart = &block

regs.data = 65

print(regs.status, regs.data, regs.baud)
1 65 115200

A volatile place is one whose reads and writes are effects, not value computations. It may change without the program changing it, and reading it may itself do something. So the compiler emits exactly the accesses the source wrote, exactly once each, in the order written — never adding, dropping, merging, or moving them relative to one another.

It constrains the compiler, not the machine. No atomicity, no ordering against another core, no protection from a torn read. C spent two decades learning this; for talking to another thread the tools are &sync T, Mutex[T], Atomic[T] and explicit orderings, and none of them is spelled volatile. A program that reaches for this word to share a counter has written a race with a keyword in front of it.

It qualifies storage, and a value read out of storage is an ordinary value

This is the rule everything else follows from. regs.status above has type u32 — not volatile u32 — because what a load hands back is a number, and a number is not somewhere a device can write. What is qualified is the place, and the qualifier lives in the three types that name a place somebody else owns:

writtenwhat is qualified
status: volatile u32a struct field
bank: [4]volatile u32an element — a GPIO bank
p: *volatile u32a pointee — the lone register

Everywhere else the type being written is the type of a value: what a var holds, what a parameter receives, what a function hands back, what a type argument stands for.

var x: volatile u32 = 0u32

print(x)
'volatile u32' is the type of *storage*, and this is a value — what a read of a volatile place hands back is an ordinary 'u32'. The qualifier goes where the storage is named: a struct field, an element, or the pointee of a '*T', as '*volatile u32'

The diagnostic says which spelling was wanted, because a program that writes var x: volatile u32 almost always meant *volatile u32.

Per field, not per aggregate. C also allows volatile struct Uart; sysl does not, and the block above shows why. baud is a shadow value the driver keeps in ordinary memory beside the registers, and every real device header has one — a reserved word, a cached configuration, a software flag. A qualifier on the whole struct would sweep it in:

struct Uart
    status: u32
end Uart

var p: *volatile Uart = null

print(1)
a register block is qualified one field at a time

Qualifying per field is the same power with the opt-out, it is what CMSIS and every other vendor header already does, and it makes the restriction below a check on one scalar instead of a walk of a type.

Only a scalar or a raw pointer may be qualified. The promise is about the load and the store the source wrote, so it is only meaningful where an access is one instruction:

  • a counted value is refused outright — a &T, a weak T, a slice and a string come with retains and releases the compiler places, and a retain that may not be elided is not a request anybody could act on;
  • a trait object is refused for a plainer reason: it is two words, so touching one is two accesses whatever the source says, and the table beside the value is this program’s rather than a device’s;
  • a constrained subtype is refused, and this one is about trust rather than instructions. A Level = int within 0..7 is the claim that a value has been checked; a register holds whatever the device put there. So the register is declared at the base type and what comes back is converted — one written conversion, checked, at the point the value arrives;
  • a data enum is refused — a tag beside a payload is more than one access however it is written.

A simple enum is admitted, since it is its underlying integer: it is one load, and it is the spelling a register’s mode field wants. So is a bitfield, which is the one case where the access reaches more than the field named — the qualifier applies to the container the field is bits of, so a write is a read-modify-write of the whole of it. @packed on attributes states that rule and what it costs a driver.

A struct that holds a register carries no invariant. A check is a call taking every field, so it reads the whole block however few fields the clause names — an invariant written over the shadow value beside the registers would make writing that shadow an access to the device. There is nothing to hold the clause true either: a device changes a register between the check and the instruction after it. A register is checked where it is read.

What the compiler does with it

A qualified access lowers to LLVM’s load volatile / store volatile, which is exactly the barrier this needs: it stops the reordering, elision and merging above, and stops nothing else. There is no runtime cost and no runtime component.

  • A qualified field is reached at its own address, not lifted out of the block. Reading a Uart to find out what is in status would also read data, and reading a data register is how a FIFO is popped — so a qualified field gets a place walk, one getelementptr and one load volatile, with nothing else touched.
  • A whole block copied is a copy of every register in it. var u = *regs is one access to each, which is as much an effect as one access to one of them, so the aggregate access is marked too. Whether a driver wants that is the driver’s business; what it does not get is a silent unqualified read of hardware.
  • An address taken of a register is the address of a register. &regs.status has type *volatile u32, so a driver may hand one register to a helper and every access the helper makes is still an access to a device. Without that, a driver would have to be one function.
  • A type parameter never binds to a qualified type. The loads and stores a generic body emits are its accesses, written once and shared by every instantiation, so it cannot promise to have written the ones a particular caller had in mind.

volatile is not reserved. It is special only in front of another type, so a program with a variable, a field, a function, or a type of its own by that name still compiles — the same arrangement sync has after &:

var volatile = 7

print(volatile + 1)
8

[]const T composes with it and means a different thing. const is a property of the view — these elements may not be written through this handle — while volatile is a property of the element. A read-only device register is []const volatile u32, and both words are doing work.

Where defer sits

defer is the model’s answer for what the language does not own: a descriptor from open, a FILE* from fopen, a block from malloc, a lock taken from a mutex. ARC gives back a reference, a string, and a slice’s backing without being asked, and knows nothing about those.

Three facts place it against everything above:

  • A deferred statement runs before the block’s ARC releases, so every local it names is still alive when it runs — including the one holding the resource it is closing. Leaving from the middle unwinds outward: the innermost block runs its deferred statements and then gives up its counts, then the block outside it does the same.
  • It owns nothing and allocates nothing. defer takes no count, makes no box, and adds no word to any value; a program that does not use it emits nothing for it. That is what keeps it available under no alloc, where the resources it releases are the only ones there are.
  • A trap runs nothing. A trap aborts without stack cleanup, and defer does not qualify that: a broken invariant means the program’s model of itself is already wrong, and running cleanup against that state is how a corrupt program writes its corruption to disk on the way down.

What it is not is a destructor. A destructor belongs to a type and runs wherever a value of that type dies; defer belongs to one place in one body and runs for the resource that body took. Both exist, and the next section is why neither replaces the other.

A destructor

impl Drop for T says what a type does when the last reference to one of its values goes. It declares one member, drop(self), and it answers nothing:

struct Handle
    id: int

impl Drop for Handle
    drop(self) = print("closing", self.id)

hold()
    var h: &Handle = Handle(7)
    print("working")

hold()
print("done")
working
closing 7
done

It is for the resource the language does not manage. ARC returns the storage and releases whatever the value holds; what it cannot do is close a descriptor, unmap a region, or hand a handle back to the C library that made it. Those live at the far end of a *T or behind an integer, and nothing about either says it is owned. The destructor is where that is said, once, beside the type.

defer is the other way to say it, and neither replaces the other. defer close(f) covers every site a program can name. Under ARC a value can also die where there is no site to write one — and that case is the whole argument for this being a capability rather than a shorthand:

struct Handle
    id: int

impl Drop for Handle
    drop(self) = print("closing", self.id)

hold()
    var xs: []&Handle = [Handle(1), Handle(2)]
    print("holding", xs.len)

hold()
print("done")
holding 2
closing 2
closing 1
done

Nothing in that program names the moment either element dies. The slice goes, and its elements go with it — so no defer could have been written, and a leak there would not be something the author could have prevented. Where both would work, the destructor is the better one: it is written once, and a caller cannot forget it.

It costs nothing to have. Release already calls through a per-payload hook, and a destructor is a call at the top of that hook. A type without one produces exactly the hook it always did.

The four limits, each a consequence of where it runs

It runs before the value’s own references are released, so it is handed self intact and a field may be read to close what it names. It borrows rather than taking a count — the count is already zero, and taking one would resurrect the object into a second teardown.

It is not called for a value that never reached the heap. A value type is copied, and a copy is not a second resource: there is no single point of death to hook, and running it per copy would close one descriptor several times. So a destructor is for a type held behind a &T, and a program that puts one on a type it then passes by value gets no destructor rather than a wrong one.

It is not called for a value in a reference cycle, whose count never reaches zero. That is not a new consequence of this feature but the existing cost of counting rather than collecting — the storage already leaks there. A weak T is what breaks a cycle.

It is not called for module storage when the program ends. Storage that lasts the whole run is never let go of, so its count never reaches zero. There is no exit pass and there will not be one: a process exiting is what returns what it held, and running destructors at exit is the feature C++ has spent decades regretting, because the order two statics come apart in has no good answer. What it costs is that a buffered writer held in a static is not flushed at exit; the program flushes it.

No order is promised among siblings. Two values that die together — the elements of a slice, the fields of a struct — come apart in an order that follows the teardown worklist, which today makes it last-to-first. A program that needs one before another has to say so.

Crossing a concurrency domain

A &T permits aliasing and mutation through any reference, which is safe within one concurrency domain and nowhere else: its refcount is non-atomic, and two threads touching it would race. So a &T may not leave its domain, and crossing one copies by default — which is what process IPC does anyway, and what keeps the ordinary path free of atomics.

The exception is &sync T, whose refcount is atomic. It is a distinct type from &T with no conversion either way, and which one an object is is chosen where it is allocated:

struct Cell
    n: int
end Cell

send(c: &sync Cell) -> int = c.n

var c: &Cell = Cell(1)

print(send(c))
'&Cell' and '&sync Cell' are distinct types, and neither converts to the other: a count is atomic or it is not from the moment the object is allocated, and a conversion would put an ordinary retain beside an atomic one. Allocate Cell as a '&sync Cell' where it is constructed

A conversion would put an ordinary retain beside an atomic one, so the count is atomic or it is not from the moment the object exists. &sync T makes the reference safe to share, not the object safe to mutate — that still wants a Mutex.

@crossing — where the rule is asked

The rule above says what may cross. @crossing says where: it is the annotation a facility writes above the function that hands a value to another domain, naming the parameters it hands it through. sysl.posix.threads.spawn is declared with one, and so is any package binding a scheduler of its own:

struct Cell
    n: int
end Cell

struct State
    cell: &Cell
end State

@crossing(s)
start(s: *State) -> int = s.cell.n

var c: &Cell = Cell(1)
var st = State(c)

print(start(&st))
what 's' of 'start' points at reaches another concurrency domain, so every count inside it has to be atomic

A *T parameter is looked through, and that is the whole of what the annotation buys. A raw pointer carries no refcount of its own, which says nothing about the object at the far end — and the object at the far end is what crossed. Everything else is asked about the parameter’s own type. Hold the cell as a &sync Cell and the same program compiles.

It costs nothing at run time: no code is emitted, no signature moves, and a program that satisfies it is the program it would have been without the line. What it adds is the refusal, made at each call — so a generic facility is asked afresh for every set of type arguments, exactly as a &sync Box[T] is.

A boundary nobody marks is not examined. The annotation is how a library author says a domain begins here, and there is no way for the compiler to guess: a scheduler in a package looks like any other function until somebody writes the line.

Hazard summary

segfault sourceprevented in the safe subset by
use-after-free / double-freeARC on &T; weak degrades to Option and never dangles
null dereferencenon-null references; nullable is Option
out-of-boundslength-carrying arrays and slices, checked everywhere
slice outliving its bufferthe slice’s owner word retains it; escaping locals are promoted
refcount race across threads&sync T is atomic; a &T may not cross a domain, checked at a @crossing parameter
dangling or wild pointerimpossible without *T

Only *T opts out, and it opts out visibly.

One hazard is not on that list: racing on the fields of an object two threads deliberately share. Preventing that needs proof of exclusive access, which is the thing this language trades away, so it is answered by a Mutex and by convention rather than by the type checker. It takes &sync or *T to reach the situation at all, so it is at least as greppable as everything else here.

The two worlds, one language

  • Application, server, utility: T and &T, plus weak, slices, and arrays. Safe, pleasant, ARC-managed.
  • Kernel, driver, allocator-free: T, *T, fixed arrays, slices, and manual malloc/free. A module that never creates a &T emits no allocation and no allocator dependency, exactly like C, and stays bounds-checked wherever it uses arrays and slices.

The boundary is not a convention — it is compiler-enforced through the allocator capability. What it gates is allocation, not ownership. Allocator-free code may hold, pass, copy, and drop a &T or a heap-backed slice that something else created: retain and release are a few instructions, and the free path goes through the object’s own deallocation hook. What it may not do is make one. That is what lets a driver keep the &Device its bus manager handed it, and a no alloc parser read a heap-backed slice it was given — neither of which is expressible if ownership stops at the boundary.


Next: arrays and slices — the two sequence types, and what a view keeps alive.

Search

Esc
to navigate to open Esc to close