Pointers & Slices
Pointer types
| Type | Meaning |
|---|---|
*T | Mutable pointer to T (nullable) |
*!T | Non-null mutable pointer to T |
*const T | Immutable pointer to T |
*volatile T | Pointer 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
var x: i64 = 42;
let ptr: *i64 = &x;Dereferencing
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:
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 u16Arrays
Fixed-size, stack-allocated:
let nums: [4]i64 = [1, 2, 3, 4];
let first = nums[0];
let count = nums.len; // compile-time constant: 4var 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.
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:
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):
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 sliceThe 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.
| Type | Meaning |
|---|---|
[]T | Mutable slice |
[]const T | Read-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.
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: staticAny 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.
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.
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.
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).
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:
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 falsepis a*i32,*u32,*i64or*u64.@atomic_loadalso takes a*constpointer; the other three write throughp, 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_loadand@atomic_addgive the word’s type,@atomic_casabool,@atomic_storenothing. The values are integers, taken at the word’s width.@atomic_addwraps 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_casonly reports whether it swapped. A loop that retries loads the word again:
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_storeis 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
@packedstruct 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:
// 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:
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:
trap: index out of bounds at main.tape:12:16The 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:
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] againA 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:
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.
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
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:
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:
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 nullA *!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: