Pointers & Slices

Pointer types

TypeMeaning
*TMutable pointer to T (nullable)
*!TNon-null mutable pointer to T
*const TImmutable pointer to T
*volatile TPointer to device memory: every access through it happens exactly as written (Volatile pointers)

Memory that threads or processors share is changed with the atomic operations.

Taking an address

tape
var x: i64 = 42;
let ptr: *i64 = &x;

Dereferencing

tape
let val: i64 = *ptr;
*ptr = 100;

Dereference uses prefix * (same as C).

p[i] is the element i places after *p, a T for p: *T, and it steps by T’s size. p can be any pointer expression, not only a variable:

tape
fn name_ptr() -> *const u8 { ... }

let first: u8 = name_ptr()[0];          // one byte
let hi: u8 = (&word as *const u8)[1];   // the second byte of a u32
(&samples[0])[2] = 7;                   // samples: [8]u16, so a u16

Arrays

Fixed-size, stack-allocated:

tape
let nums: [4]i64 = [1, 2, 3, 4];
let first = nums[0];
let count = nums.len;  // compile-time constant: 4

var a: [N]T; starts zeroed, and var b = a; is a copy. A literal initializing a [N]T has exactly N elements: one too many or too few is a compile error ('nums' is [4]i64, given a literal of 5 elements), so a table that grows without its count being bumped is caught where it is written, not found at run time. [] zeroes the array like no initializer, wherever a literal initializes one: a local, a struct field, a component prop, an argument, a row of a nested literal.

Each element must be of the element type, wherever the literal goes: a binding, an argument, a return value, an assignment. With fn mean(v: [2]f64), mean([1, 2]) is a compile error (element 0 is f64, given an integer literal: write [1.0, 2.0]), and so is mean(["a", "b"]).

Arrays nest: [2][3]i64 is two rows of three, row-major.

tape
var grid: [2][3]i64 = [[1, 2, 3], [4, 5, 6]];
grid[1][2] = 60;
let rows = grid.len;     // 2
let cols = grid[0].len;  // 3
for (row in grid) { io.println("{row[0]}"); }

A pointer to an array indexes and has .len like the array itself:

tape
fn total(m: *[2][3]i64) -> i64 {
    var s: i64 = 0;
    for (i in 0..m.len) {
        for (j in 0..m[i].len) { s = s + m[i][j]; }
    }
    return s;
}
let t = total(&grid);

Slices

Bounded view into contiguous memory (pointer + length):

tape
var nums: [4]i64 = [1, 2, 3, 4];
var slice: []i64 = nums[1..3];  // elements at index 1, 2
let len = slice.len;
let head: []i64 = nums[..2];    // from the start: index 0, 1
let tail: []i64 = nums[2..];    // to the end: index 2, 3 (length 4 - 2)
let incl: []i64 = nums[1..=2];  // end included: index 1, 2
let full: []i64 = nums[..];     // full array as slice

The same ranges work on slices and strings, and on pointers when the end is given (p[a..b], p[..b]): a pointer has no length, so p[a..] and p[..] don’t compile. A pointer to an array (*[N]T) is the exception: it has the array’s length, so p[..] is all N elements.

TypeMeaning
[]TMutable slice
[]const TRead-only slice

An array literal passes where a slice is expected. A []const T literal of constants (numbers, enum variants, named consts — not expressions on them — and text) is a read-only array laid out once, like a const table, so its slice is valid for the whole program: you can return it, keep it in a global or a component, or give it to a prop in a view.

tape
fn widths() -> []const i64 { return [30, 0, 20]; }   // ok: static
var sizes: []const i64 = [30, 0, 20];                // ok: static

// in a view:
Stack { sizes: [30, 0, 20]; ... }                    // ok: static

Any other literal (an element that is a variable or a call, or a []T) lives in the function’s frame: fine for an argument or a local, but a compile error where the slice would outlive the function (a return, a global, a component’s prop or var). Keep such an array in a named array (a component var bars: [3]i64) and give a slice of it (bars[..]).

Const views

A []T passes where a []const T is expected, and a *T where a *const T is. The other way round is a compile error: a read-only view never turns back into a writable one, whether by let, assignment, argument, return value or struct field.

tape
fn fill(buf: []u8) { buf[0] = 0; }
fn show(text: []const u8) { io.print(text); }

fn demo(v: []const u8, s: string) {
    show(v);                  // ok
    fill(v);                  // error: cannot use '[]const u8' as '[]u8'
    let w: []u8 = s[0..2];    // error: a string's bytes are read-only
    v[0] = 1;                 // error: 'v' is a []const u8: it can't be changed through
}

Slicing keeps const-ness: a sub-slice of a []const T (or of a *const T, or of a const array) is a []const T, and &v[i] of one is a *const T. There is no cast that removes const either: p as *T with p: *const T, v as []u8 with v: []const u8, and the same to any other pointer or slice type are compile errors. Declare the parameter or field []const T / *const T instead.

tape
fn peek(p: *const i64) -> i64 {
    let w: *const u8 = p as *const u8;   // ok: still const
    let q: *i64 = p as *i64;             // error: cannot cast away const
    return *p;
}

The one way out is through an integer, (p as i64) as *i64: the escape hatch for FFI and kernel code, where an address is just a number. It is spelled out where it happens, so it is easy to find.

Volatile pointers

Device registers and framebuffers are memory that something besides your program reads or writes. Reach them through a *volatile T, and every read and write through it is a volatile access: the compiler performs each one exactly once, in the order written, with exactly the width of its type.

tape
pub struct Uart { data: u32; status: u32; ctrl: u32; }

fn uart() -> *volatile Uart { return 0xFEB0_0000 as *volatile Uart; }

fn putc(c: u8) {
    let u = uart();
    while ((u.status & 0x20) == 0) {}   // reads the register on every test
    u.data = c as u32;                  // one 4-byte store
}

fn fill(fb: *volatile u32, n: i64, rgb: u32) {
    for (i in 0..n) { fb[i] = rgb; }    // n stores, in order
}

Without volatile the optimizer may read u.status once and keep the value, or drop a store nothing reads back. With it, a volatile access is never removed (u.status; alone still reads the register), duplicated, moved out of a loop, merged with another access to the same place, split or widened (two neighbouring u32 registers are two 4-byte accesses), or performed when its condition doesn’t hold. Other memory accesses and calls don’t move across one either.

What is volatile: *p, p[i] and p.f through a *volatile T, and the fields and elements of a struct or array reached that way (p.chan[2].ctrl). &p.ctrl is a *volatile u32.

The qualifier works like const: *T converts to *volatile T on its own, the other way is a compile error, and as can’t remove it. Combine them as *const volatile T (a read-only register).

tape
fn read_only(s: *const volatile u32) -> u32 { return *s; }

fn demo(p: *volatile u32, raw: *u32) {
    let v: *volatile u32 = raw;   // ok: adding volatile
    let q: *u32 = p;              // error: the accesses through it would no longer be volatile
    let c = p as *u32;            // error: cannot cast away volatile
}

A volatile access moves one integer, float, bool, enum or pointer. So reading or writing a whole struct or array through a volatile pointer (let r = *u;), slicing through one (p[0..4]), and calling a method on one are compile errors: read and write the registers one by one. u.ctrl |= 1 is one load and one store.

Volatile is not atomic and not a CPU memory barrier: u.ctrl |= 1 can be interrupted between its load and its store, and another core may see accesses in another order. Memory that threads share is reached with the atomic operations; a bare barrier instruction (mfence, dmb) comes from a #bytecode function.

Atomic operations

Four builtins make one access to a word indivisible across threads and processors, in every profile (t0 kernels included) and in both the VM and native code:

tape
let v = @atomic_load(p);                // the value at p
@atomic_store(p, v);                    // v to p
let before = @atomic_add(p, n);         // *p += n, wrapping; the value before
let swapped = @atomic_cas(p, old, new); // *p was old: it is now new, true; else false
  • p is a *i32, *u32, *i64 or *u64. @atomic_load also takes a *const pointer; the other three write through p, so they don’t. Narrower words, floats, pointers and vectors are not atomic words.
  • Not a *volatile T: device memory has no atomic read-modify-write. Atomics are for ordinary memory that threads share.
  • @atomic_load and @atomic_add give the word’s type, @atomic_cas a bool, @atomic_store nothing. The values are integers, taken at the word’s width. @atomic_add wraps like every integer add and returns the value before the add, so the decrement that brings a count to zero is @atomic_add(p, -1) == 1. To subtract, add the negation.
  • @atomic_cas only reports whether it swapped. A loop that retries loads the word again:
tape
fn store_max(p: *i64, v: i64) {
    var cur = @atomic_load(p);
    while (v > cur && !@atomic_cas(p, cur, v)) { cur = @atomic_load(p); }
}
  • Sequentially consistent, and nothing else. All atomic operations of a program happen in one order every thread agrees on, and whatever a thread wrote before an atomic store is visible to a thread whose atomic load sees that store. There are no weaker memory orders to choose from.
  • Never moved or removed. Each atomic operation runs exactly once where it is written, even when its value is unused, and no other memory access or call moves across it: a plain store before @atomic_store is done before it.
  • Aligned words. Every local, field, array element and heap block of these types is aligned to its size. A field of an @packed struct may not be, so @atomic_add(&packed.n, 1) is a compile error. An address you compute yourself is your responsibility: there is no run-time alignment check.

On x86-64 a load is a mov, a store an xchg, and add and compare-and-swap are lock xadd and lock cmpxchg; on arm64 they are ldar, stlr, and an ldaxr / stlxr loop. The VM runs its tasks one at a time, so there the plain operation is already indivisible.

A t1 or t2 program shares state through the sync module; the builtins are what it is built on, and what kernel code uses directly:

tape
// a spin lock in t0 kernel code
pub fn spin_lock(l: *u32) { while (!@atomic_cas(l, 0, 1)) { pause(); } }
pub fn spin_unlock(l: *u32) { @atomic_store(l, 0); }

Copying and filling memory

Two builtins copy and fill memory at the machine’s speed, in every profile (t0 included) and in the VM and native code alike:

tape
var a: [8]u8 = [1, 2, 3, 4, 5, 6, 7, 8];
var b: [8]u8;
@move_bytes(&b[0], &a[0], 8);     // b is a copy of a
@move_bytes(&a[2], &a[0], 4);     // overlapping is fine: a is 1 2 1 2 3 4 7 8
@fill_bytes(&b[0], 0, 4);         // b is 0 0 0 0 5 6 7 8

@move_bytes(dst, src, n) copies n bytes and is correct when the two ranges overlap, in either direction. @fill_bytes(dst, byte, n) sets n bytes to the byte’s low 8 bits. A length of 0 or less does nothing. Like any access through a raw pointer, they don’t check bounds: with slices, use bytes.copy, move and fill, which check lengths and are built on these. The destination can’t be a pointer to const. In a @constant_time function the length must be public.

Bounds checking

In t1 and t2, indexing an array or slice outside 0..len stops the program instead of touching memory it doesn’t own:

plaintext
trap: index out of bounds at main.tape:12:16

The same goes for a sub-slice a[s..e] that reaches past the end, which reports trap: slice range out of bounds at file:line:col.

A range has to satisfy 0 <= a <= b <= len (for a[s..], 0 <= s <= len). The message goes to stderr and the process exits with code 101, the same in tape run and in a native build. An index the compiler can prove is in range costs nothing at runtime: a literal (nums[0] on a [4]i64); x[i] of a local array, slice or string x in for (i in 0..x.len) when nothing in the function takes x’s address and the loop never assigns x or i, takes i’s address or calls a method on x; and i, i + k or i - k (k a literal) in a loop whose bounds are constants that keep every value inside a fixed array (g[y][x - 1] for x in 1..63 into a [64]). Every other index keeps its check, and a program behaves the same either way. In t0 indexing is unchecked, and indexing through a raw pointer (p[i] with p: *T) is never checked — it has no length to check against. Tests can assert a trap with @assert_trap(expr).

Pointer arithmetic (t0/t1)

@offset(p, n) is the address n elements past p, stepped by the size of what p points at; a negative n goes back. It has p’s type, and you can use it directly:

tape
var px: [64]u32;
let row: *u32 = &px[0];
let next: *u32 = @offset(row, 8);       // px[8]'s address: 32 bytes on
*@offset(row, 9) = 0xFF00FF;            // px[9]
let v: u32 = @offset(row, 8)[1];        // px[9] again

A pointer and a number don’t add: p + n is a compile error that says to use @offset. For a byte offset, which is rarely what you want, cast through i64:

tape
let at: *u8 = (p as i64 + 3) as *u8;    // 3 bytes past p, whatever p points at

@offset of a pointer to an array (*[4]u32) is refused, since its element is the whole array: offset the first element’s address instead, @offset(&a[0], n).

Pointers and integers

A pointer and an integer never turn into each other on their own: every conversion is an as you can see.

tape
let addr: i64 = p as i64;        // ok
let q: *u8 = addr as *u8;        // ok
let a: i64 = p;                  // error: a pointer becomes an integer only with 'as'
let r: *u8 = addr;               // error: an integer becomes a pointer only with 'as'
let none: *u8 = null;            // a null pointer is `null`, not `0`

This is what keeps *const T honest: without it, let a: i64 = cp; let q: *T = a; would turn a read-only pointer into a writable one with nothing at the use site to show it.

Null checking

tape
let p: *i64 = get_maybe_null();
if (p != null) {
    io.println("{*p}");
}

A pointer compares with null or with another pointer, never with an integer: p == 0 is an error (“cannot compare a pointer with an integer: compare with null”), because 0 is an integer, not a null pointer. To compare an address with a number, convert it: p as i64 == 0x1000. Function pointers compare with null or each other, with == and !=.

Non-null pointers

A *!T can never hold null, so code that receives one needs no null check:

tape
fn process(data: *!u8) {
    // guaranteed non-null — no check needed
}

The compiler only lets a value into a *!T when it can’t be null: another *!T, an address (&x), an unwrapped optional pointer (p!, which traps if p is null), or an array. (A string literal is read-only, so it goes only where a *const u8 is declared.) Assigning null, or a plain *T that might be null, is a compile error:

tape
var raw: *u8 = find();
process(raw);              // error: argument 1 of 'process' is 'data', *!u8, given *u8
process(raw as *!u8);      // explicit: you vouch it isn't null
let opt: ?*u8 = find();
process(opt!);             // checked: traps if null

A *!T converts to *T freely. An optional pointer ?*T does not: it is unwrapped (opt!, or or) before you read through it or pass it as a *T (see Optionals). A variable whose type is inferred from &x is a plain *T, so it can later be set to a nullable pointer.

A *const T is read-only the same way: *p = x, p.field = x and p[i] = x through one are compile errors, and it doesn’t convert to *T.

Last modified: