Strings and Byte Buffers
How to work with strings, byte arrays, and buffer operations in Esper IR.
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.
Esper has no string type. Strings are byte sequences in linear memory, managed entirely by the frontend. This guide shows how a compiler lowers common string operations into Esper IR.
What we’re lowering
Consider a language with built-in string operations:
func greet(name: String) -> String {
let greeting = "Hello, "
return greeting + name
}
func countChars(s: String) -> Int32 {
var count = 0
for ch in s {
count += 1
}
return count
}
At the source level, strings are opaque values with high-level operations like concatenation and iteration. The frontend must decide a concrete memory representation and lower every operation to pointer arithmetic, byte loads, and loops. Esper provides the building blocks; the frontend makes the design choices.
Choosing a representation
Before emitting any IR, the frontend must pick a string representation. The two most common are:
| Representation | Layout | Pros | Cons |
|---|---|---|---|
| Null-terminated (C) | ptr to bytes ending in 0x00 | Simple, interop with C | Length requires a scan, no embedded nulls |
| Length-prefixed (Rust, Go) | (ptr data, i32 len) | O(1) length, embedded nulls ok | 8+ extra bytes per string |
The rest of this guide shows both, starting with null-terminated strings since they are simpler to lower.
C-style null-terminated strings
Pseudo-code: string length
func strlen(s: *UInt8) -> Int32 {
var len = 0
while s[len] != 0 {
len += 1
}
return len
}
Step-by-step lowering
- Parameters:
sis aptrto the first byte. We allocate space for a mutable counterlenasi32. - The loop condition (
s[len] != 0): Compute the addresss + lenwithadd i64, load a single byte withload8.u(unsigned so 0x00-0xFF maps to 0-255), then test witheqz. - Why
load8.u? Characters are unsigned byte values. Usingload8.swould sign-extend bytes above 0x7F to negative integers, which would break the zero check for valid UTF-8 continuation bytes. - Incrementing: Plain
add i32on the counter, then jump back to the check.
Esper lowering
func $strlen(%0 ptr) -> i32 {
entry:
%1 = ldi i32 4
%2 = salloc %1 // allocate space for len
%3 = ldi i32 0
store i32 %2, %3 // len = 0
jmp check
check:
%6 = load i32 %2 // load len
%7 = add i64 %0, %6 // s + len
%8 = load8.u i32 %7 // load byte (zero-extended)
%9 = eqz i32 %8 // is it the null terminator?
jmp.if %9 done next
next:
%11 = load i32 %2 // load len
%12 = ldi i32 1
%13 = add i32 %11, %12 // len + 1
store i32 %2, %13 // len = len + 1
jmp check
done:
%16 = load i32 %2 // load len
ret i32 %16
}
Pseudo-code: byte comparison
func memcmp(a: *UInt8, b: *UInt8, len: Int32) -> Int32 {
for i in 0..<len {
if a[i] != b[i] {
return a[i] - b[i]
}
}
return 0
}
Step-by-step lowering
- Loop structure: A counter
iwalks from 0 tolen. Each iteration loads one byte from each buffer and compares them. - Address computation: For each buffer, the byte address is
base + i. Both useadd i64because pointer arithmetic operates at pointer width. - Early exit: We subtract the two bytes. If the result is nonzero, we return it immediately (positive means
a > b, negative meansa < b). If zero, we advance to the next byte. - Why
sub i32for the comparison? The difference of two unsigned bytes (0-255) always fits in a signedi32, and the sign of the result tells the caller which buffer is “greater.” This matches the Cmemcmpcontract.
Esper lowering
func $memcmp(%0 ptr, %1 ptr, %2 i32) -> i32 {
entry:
%3 = ldi i32 4
%4 = salloc %3 // allocate space for i
%5 = ldi i32 0
store i32 %4, %5 // i = 0
jmp check
check:
%8 = load i32 %4 // load i
%9 = ge.u i32 %8, %2 // i >= length?
jmp.if %9 equal compare
compare:
// Load byte from first buffer
%11 = load i32 %4 // load i
%12 = add i64 %0, %11 // a + i
%13 = load8.u i32 %12 // a[i]
// Load byte from second buffer
%14 = load i32 %4 // load i
%15 = add i64 %1, %14 // b + i
%16 = load8.u i32 %15 // b[i]
// Compare
%17 = sub i32 %13, %16 // a[i] - b[i]
%18 = eqz i32 %17 // is zero?
jmp.if %18 advance diff
diff:
ret i32 %17 // return nonzero difference
advance:
%21 = load i32 %4 // load i
%22 = ldi i32 1
%23 = add i32 %21, %22 // i + 1
store i32 %4, %23 // i = i + 1
jmp check
equal:
%26 = ldi i32 0
ret i32 %26
}
Length-prefixed strings
Pseudo-code
struct StringView {
var data: *UInt8 // offset 0, size 8 (ptr)
var length: Int32 // offset 8, size 4
}
func stringLen(s: *StringView) -> Int32 {
return s.length
}
func charAt(s: *StringView, i: Int32) -> UInt8 {
return s.data[i]
}
Step-by-step lowering
- Struct layout:
StringViewis 12 bytes.datais aptrat offset 0,lengthis ani32at offset 8. The frontend computes these offsets at compile time. - Length access (
s.length): Load the parameter (a pointer to the struct), add offset 8, thenload i32. This is O(1), unlike the null-terminatedstrlenwhich requires a loop. - Character access (
s.data[i]): First load thedatapointer from offset 0 withload ptr, then add the indexi, then load a byte withload8.u. This is two loads: one to dereference the struct, one to dereference the data pointer. - Why a struct pointer? Passing the struct by pointer avoids copying both fields on every call. For hot paths, the frontend could also flatten the struct into two parameters
(ptr, i32)to avoid the indirection.
Esper lowering
// Get the length of a string view
func $string_len(%0 ptr) -> i32 {
entry:
%1 = ldi i32 8
%2 = add i64 %0, %1
%3 = load i32 %2 // load length field
ret i32 %3
}
// Get a byte at index i
func $string_at(%0 ptr, %1 i32) -> i32 {
entry:
%2 = load ptr %0 // load data pointer
%3 = add i64 %2, %1 // data + index
%4 = load8.u i32 %3 // load byte
ret i32 %4
}
Copying memory (memcpy)
Pseudo-code
func memcpy(dst: *UInt8, src: *UInt8, len: Int32) {
for i in 0..<len {
dst[i] = src[i]
}
}
Step-by-step lowering
- Loop counter:
icounts from 0 tolen, same pattern asmemcmp. - Reading a byte: Compute
src + i, thenload8.uto load one byte. - Writing a byte: Compute
dst + i, thenstore8to store.store8truncates the value to 8 bits before writing, so only one byte is stored regardless of thei32value. - Why byte-by-byte? This is the simplest correct lowering. A real backend would recognize this loop pattern and emit a block copy instruction (
rep movsbon x86,memcpycall on ARM). The IR should be correct first; the optimizer makes it fast.
Esper lowering
func $memcpy(%0 ptr, %1 ptr, %2 i32) {
// params: dst=%0, src=%1, len=%2
entry:
%3 = ldi i32 4
%4 = salloc %3 // allocate space for i
%5 = ldi i32 0
store i32 %4, %5 // i = 0
jmp check
check:
%8 = load i32 %4 // load i
%9 = ge.u i32 %8, %2 // i >= len?
jmp.if %9 done copy
copy:
// src[i]
%11 = load i32 %4 // load i
%12 = add i64 %1, %11 // src + i
%13 = load8.u i32 %12 // load byte from src
// dst[i] = src[i]
%14 = load i32 %4 // load i
%15 = add i64 %0, %14 // dst + i
store8 i32 %15, %13 // store byte to dst
%17 = load i32 %4 // load i
%18 = ldi i32 1
%19 = add i32 %17, %18 // i + 1
store i32 %4, %19 // i = i + 1
jmp check
done:
ret void
}
Key takeaways
- Strings are just pointers to byte sequences in linear memory
- The frontend decides the string representation (null-terminated, length-prefixed, etc.)
- Byte access uses
load8.u/store8(narrow load/store) - The
ptrtype is used for addresses; integer arithmetic computes offsets