of Eskiu
A complete introduction to systems programming
v0.7.0, 2026
Close to the metal. Far from the noise.
This book is for everyone who will learn from it, and for those who made sure I was still standing when I wrote it: my mother, my grandparents, my husband, and three cats who never once doubted me.
Contents
Part I: Tutorial
Part II: Language Reference
Why this book exists
This book teaches Eskiu from zero. You do not need to know any other programming language. You need a computer, a terminal, and the willingness to type things and see what happens.
Programming is not a subject you read about and then understand. It is something you do. Every example in this book is short enough to type by hand. Type them. Run them. Change a number and see what breaks. That is how the knowledge becomes yours.
Eskiu is a systems language with the power of C and the immediacy of a scripting language. It compiles to native code through LLVM, giving you native performance, explicit memory, and direct access to any C library, so it does the work you would normally reach for C to do. But it spares you C's ceremony for small things: eskiuc run file.esk compiles and runs in a single step, and a #!/usr/bin/env eskiuc run shebang turns a .esk file into an executable script, the way Python or Ruby would. One compiler, one build command, and a mental model small enough to hold in your head.
The language has no garbage collector. Memory is yours to manage. You ask for it when you need it and give it back when you are done. This sounds more intimidating than it is. By the end of Chapter 6 you will do it without thinking.
Every chapter follows the same pattern. A concept is explained in plain language. Then shown in code. Each chapter ends with a summary and exercises. Do the exercises, even the easy ones, especially the easy ones.
By the end you will have read the complete language, managed memory explicitly, called C libraries directly, spawned threads, and read through a real cryptographic pipeline that runs 2.5× faster than the hand-written C it replaced.
Eskiu is named after a cat.
Tiny → Minusculiny (minúscula + tiny) → Kulini → Eskiulini → Eskiu.
As of v0.3.0, Eskiu is self-hosting: the whole compiler (lexer, preprocessor, parser, type checker, and code generator) is written in Eskiu itself (selfhost/), reaches a three-stage bootstrap fixpoint, and its code generator is feature-complete against the reference C++ compiler. Chapter 27 tells that story.
Hello, Eskiu
Every journey in programming starts with a single program that does almost nothing. It prints a line of text and exits. This is Hello World, a tradition as old as the C language itself. Its purpose is not the output. Its purpose is to prove that your tools work: the compiler is installed, you can type code, and the result runs on your machine. Everything else builds on that.
1.1 Installing the compiler
Go to eskiu-lang.org and download the binary for your platform. Unpack the archive and copy eskiuc to somewhere on your PATH. /usr/local/bin works on both macOS and Linux. You will also need Clang to link your programs:
# macOS xcode-select --install
# Ubuntu / Debian sudo apt install clang
# Verify eskiuc --version clang --version
1.2 Your first program
Create a file called hello.esk:
extern int printf(string fmt, ...);
int main() {
printf("Hello, Eskiu!\n");
return 0;
}
Compile and run:
eskiuc hello.esk -o hello # compile + link in one step ./hello
Output: Hello, Eskiu!
1.3 What just happened
The first command compiled and linked your program in one step. eskiuc invokes the LLVM backend to produce a native object file, then automatically calls the system C toolchain ($CC, then cc / clang / gcc) to link it into an executable, the same approach rustc and clang use. The second command runs the result.
To stop at the object file (for example to link multiple files yourself), give the output a .o suffix or pass -c:
eskiuc hello.esk -c -o hello.o # object file only, no linking
1.4 Line by line
The first line declares a function from the C standard library:
extern int printf(string fmt, ...);
extern means: this function exists somewhere else. I am just telling you its name and signature. The ... means it accepts any number of additional arguments after the format string.
The main function is where every Eskiu program begins. The OS calls it when you run the program. Returning 0 signals success.
Inside main, printf prints the format string. \n is a newline. It moves the cursor to the next line after printing.
1.5 Making a change
The best way to understand code is to change it. Try printing a number:
printf("The answer is %d\n", 42);
Recompile and run. The %d placeholder is replaced by 42.
Variables and Types
A variable is a named location in memory that holds a value. In Eskiu every variable has a type, and that type never changes. The compiler uses the type to know how much memory to reserve, how to interpret the bits stored there, and what operations are valid. Types are not bureaucracy; they are the compiler's way of catching mistakes before your program runs.
2.1 Declaring a variable
Two equivalent forms:
let x: int = 5; // let-style with type annotation int x = 5; // C-style
Both are identical to the compiler. The let form is useful when the annotation adds clarity. The C-style form is more compact.
2.2 The primitive types
Eskiu's built-in types:
Type Size Example Use it for
int 32 bits 42, -7 Everyday whole numbers
int64 64 bits 9999999999 Large values, file sizes, timestamps
uint8 8 bits 0–255 Bytes, raw binary data, pixel values
uint32 32 bits 0–4 billion Large unsigned counts, bit masks
float 32 bits 3.14 Decimal numbers
double 64 bits 3.14159265 High-precision decimal numbers
bool 1 bit true, false Yes/no conditions
char 8 bits 'A', '\n' Single byte, unsigned (0–255)
string pointer "hello" Immutable text literals
Eskiu also has int8, int16, int32, uint16, uint64. For most code, int and uint8 cover the majority of cases.
Integer literals can be written in hexadecimal with a 0x prefix. Character and string literals support the usual escapes (\n \t \r \f \v \0 \\ \" \') plus \xNN, which writes a raw byte from one or two hex digits (so "\xC3\x91" is Ñ in UTF-8). char is an unsigned 8-bit type (0–255); when widened to int it zero-extends, not sign-extends:
int flags = 0xFF; // hex literal = 255 uint32 mask = 0xDEAD_BEEF; // larger hex
char a = 'A'; // character literal char nl = '\n'; // newline escape char bs = '\\'; // backslash char sq = '\''; // single quote
A string can be indexed to read individual characters: s[0] is the first char. Adjacent string literals are joined automatically, which keeps long text readable:
string s = "Eskiu";
printf("%c%c\n", s[0], s[4]); // Eu
string msg = "Hello, " "world!"; // adjacent literals concatenate
printf("%s\n", msg); // Hello, world!
2.3 Explicit casting
Eskiu never silently converts one type to another. When types differ, you cast explicitly, making the potential data loss visible in the code:
double x = 3.99; int n = (int)x; // 3: fractional part discarded uint8 b = (uint8)n; // fine if n fits in 0–255 float f = (float)n; // widen to float
2.4 A complete example
extern int printf(string fmt, ...);
int main() {
int age = 28;
double height = 1.75;
bool active = true;
char grade = 'A';
string name = "Eduardo";
printf("Name: %s\n", name);
printf("Age: %d\n", age);
printf("Height: %.2f\n", height);
printf("Grade: %c\n", grade);
printf("Active: %d\n", active);
return 0;
}
Output: Name: Eduardo Age: 28 Height: 1.75 Grade: A Active: 1
2.5 Global variables
A variable declared outside any function is a global. It exists for the entire run of the program and is visible to every function in the file. Reach for globals sparingly, for configuration or shared state that genuinely must outlive a single call:
extern int printf(string fmt, ...);
int counter = 0; // global
void bump() { counter = counter + 1; }
int main() {
bump();
bump();
bump();
printf("%d\n", counter); // 3
return 0;
}
Output: 3
2.6 const: immutable bindings
A const binding cannot be reassigned after its initial value. Unlike a plain variable, a const int can be used as an array size:
const int CAP = 4; int[CAP] xs; // const as array size
struct Ring { int[CAP] slots; } // const in struct field
const let base: int = 10; // const with let form // base = 11; // compile error: const cannot be reassigned
const applies to the binding: the variable cannot be reassigned. For pointer constness, see const T* (pointer to read-only data) and T* const (read-only pointer, writable data):
int[3] arr; arr[0] = 1; arr[1] = 2; arr[2] = 3; const int* r = &arr[0]; // cannot write through r // r[0] = 99; // compile error r = &arr[1]; // rebinding the pointer is fine
2.6b static: locals that persist
A static local variable has a single instance that lives for the whole program, not just one call, so it keeps its value between calls (C storage semantics). It is initialised once, and its initialiser must be a compile-time constant. A classic use is a counter that remembers how many times a function ran:
int next() {
static int c = 0; // initialised once, at load time
c += 1;
return c;
}
printf("%d %d %d\n", next(), next(), next()); // 1 2 3
Output: 1 2 3
Two static locals in different functions never share storage, even with the same name. static on a global is rejected, since a global already persists for the whole program.
2.7 enum: named integer constants
An enum assigns names to integer values. Values start at 0 and increment by 1 unless you set them explicitly. After an explicit value, numbering continues from there:
enum Color { Red, Green, Blue } // 0, 1, 2
enum Status { Ok = 0, Err = 2, Pending } // 0, 2, 3
Enum members are global integer constants used directly by name:
Color c = Green;
printf("%d\n", c); // 1
switch (c) {
case Red: printf("red\n"); break;
case Green: printf("green\n"); break;
default: printf("other\n"); break;
}
Output: 1 green
2.8 type: aliases
A type alias gives an existing type a new name. The alias and the original are interchangeable. This is Eskiu's equivalent of C's typedef:
type u8 = uint8; type Bytes = *uint8; type Int = int;
Once defined, the alias can be used anywhere the original type can, including variable declarations, function parameters, return types, and alloc:
Int add(Int a, Int b) { return a + b; }
u8 b = 200;
printf("%d\n", b); // 200
Bytes buf = alloc<u8>(4);
buf[0] = 65; buf[1] = 66;
printf("%d %d\n", buf[0], buf[1]); // 65 66
free(buf);
printf("%d\n", add(3, 4)); // 7
Output: 200 65 66 7
Functions
A function is a named, reusable block of code. You define it once and call it as many times as you need. Functions are the primary tool for breaking a program into manageable pieces. A good function does one job and can be understood on its own.
3.1 Defining a function
Return type first, then name, then parameters, then body:
int add(int a, int b) {
return a + b;
}
int result = add(3, 4); // 7
3.2 Void functions
Functions with no return value use void:
void greet(string name) {
printf("Hello, %s!\n", name);
}
greet("world"); // Hello, world!
3.3 Parameters are copies
When you pass a value, the function gets its own copy. Changes to the copy stay inside the function. Pointers (Chapter 6) are how you let a function modify the caller's data:
void try_to_double(int x) {
x = x * 2;
printf("inside: %d\n", x);
}
int n = 5;
try_to_double(n);
printf("outside: %d\n", n); // still 5
Output: inside: 10 outside: 5
3.4 Recursion
A function can call itself. Useful when a problem is naturally defined in terms of a smaller version of itself:
int factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
printf("%d\n", factorial(5)); // 120
Warning Recursive calls consume stack space. For large inputs, a loop is safer.
3.5 Declaration order and forward declarations
Definition order does not matter. A function may call another that is defined later in the same file, and two functions may call each other. You can also write a body-less forward declaration (a signature ending in a semicolon) when you prefer to state the signature up front:
int is_odd(int n); // forward declaration (optional)
int is_even(int n) {
if (n == 0) return 1;
return is_odd(n - 1); // defined below, works
}
int is_odd(int n) {
if (n == 0) return 0;
return is_even(n - 1);
}
printf("%d %d\n", is_even(10), is_odd(10)); // 1 0
Output: 1 0
3.6 Command-line arguments
To read what the user typed on the command line, give main two parameters: argc, the number of arguments, and argv, an array of strings. argv[0] is always the program's own name, so the first real argument is argv[1]:
extern int printf(string fmt, ...);
int main(int argc, string* argv) {
printf("argc = %d\n", argc);
for (int i = 0; i < argc; i += 1) {
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
$ ./prog hello 42 argc = 3 argv[0] = ./prog argv[1] = hello argv[2] = 42
3.7 Variadic functions
A function accepting a variable number of extra arguments uses ... as its last parameter. Read the extras with va_list, va_start, va_arg<T>, and va_end:
int sumv(int n, ...) {
va_list ap;
va_start(ap);
int total = 0;
for (i in 0..n) { total += va_arg<int>(ap); }
va_end(ap);
return total;
}
printf("%d\n", sumv(3, 10, 20, 30)); // 60
C default promotions apply: float args arrive as double. Read with va_arg<double>. At least one fixed parameter is required.
Control Flow
A program that executes every statement exactly once in order is not very useful. Real programs make decisions, repeat actions, and skip sections. Control flow is the set of tools that makes this possible.
4.1 if / else if / else
int score = 74;
if (score >= 90) {
printf("A\n");
} else if (score >= 80) {
printf("B\n");
} else if (score >= 70) {
printf("C\n");
} else {
printf("F\n");
}
Output: C
4.2 while
Repeats while the condition is true. The condition is checked before each iteration:
int n = 1;
while (n <= 5) {
printf("%d ", n);
n += 1;
}
Output: 1 2 3 4 5
4.3 for
Packs initialiser, condition, and step into one line. The loop variable is scoped to the body:
for (int i = 0; i < 5; i += 1) {
printf("%d ", i);
}
Output: 0 1 2 3 4
+=, -=, *=, /=, %= are shorthand for x = x op y.
4.4 break and continue
// Find first multiple of both 3 and 7
for (int i = 1; i <= 100; i += 1) {
if (i % 3 == 0 && i % 7 == 0) {
printf("found: %d\n", i); // 21
break;
}
}
// Print only odd numbers
for (int i = 1; i <= 9; i += 1) {
if (i % 2 == 0) continue;
printf("%d ", i); // 1 3 5 7 9
}
4.5 switch
Cleaner than a chain of else-if when dispatching on specific integer values. Always end each case with break:
int day = 3;
switch (day) {
case 1: printf("Monday\n"); break;
case 2: printf("Tuesday\n"); break;
case 3: printf("Wednesday\n"); break;
default: printf("Other\n"); break;
}
Output: Wednesday
4.6 for-in
The for-in loop iterates over a collection without managing an index. It works over fixed-size arrays and List<T> (or any struct with data and size fields):
// Fixed-size array
int[4] vals = {10, 20, 30, 40};
for (v in vals) {
printf("%d ", v);
}
// Output: 10 20 30 40
// List<T> import <list>; List<int> nums; List_init(&nums, 4); List_push(&nums, 1); List_push(&nums, 2); List_push(&nums, 3);
int total = 0;
for (n in nums) {
if (n == 2) { continue; } // continue correctly advances the index
total += n;
}
printf("%d\n", total); // 4 (1 + 3)
List_free(&nums);
Output: 10 20 30 40 4
For-in also works on half-open integer ranges A..B, iterating A, A+1, … B-1. The upper bound is exclusive. An empty range (A ≥ B) runs zero times:
// Range: 0, 1, 2, 3, 4
int s = 0;
for (i in 0..5) { s += i; }
printf("%d\n", s); // 10
// Variable bounds
int lo = 2; int hi = 6;
for (j in lo..hi) { printf("%d ", j); }
// Output: 2 3 4 5
The loop variable (v, n, or i above) is a copy. Modifying it does not affect the collection or the range. continue correctly advances the index.
4.7 do / while
Like while, but the condition is checked after the body, so the body always runs at least once. Use it when the first iteration must happen before there is anything to test:
int n = 0;
do {
printf("%d ", n);
n += 1;
} while (n < 3);
Output: 0 1 2
A plain while with a false condition runs zero times; the do/while above runs once even if you start it with n = 100.
Structs and Methods
A variable holds one value. A struct holds many, each with its own name and type, grouped under a single name. Methods attach behaviour to that data, so a struct carries both what it is and what it can do.
5.1 Your first struct
A struct declaration introduces a new type. Fields are declared inside the braces:
struct Point {
float x;
float y;
}
From this point on, Point is a type you can use anywhere you would use int.
5.2 Creating and using a struct
A struct literal initialises all fields at once. The named form uses field names explicitly; the positional form assigns fields in declaration order:
// Named
Point p = Point { x: 1.5, y: 2.5 };
printf("%0.1f %0.1f\n", p.x, p.y);
// Positional (declaration order)
Point q = Point { 3.0, 4.0 }; // same as { x: 3.0, y: 4.0 }
// Field-by-field Point r; r.x = 5.0; r.y = 6.0;
5.3 Methods
A method is a function inside a struct body. self refers to a pointer to the instance:
struct Rect {
float w;
float h;
float area() { return self.w * self.h; }
float perimeter() { return (self.w + self.h) * 2.0; }
void print() {
printf("Rect(%.1f x %.1f) area=%.1f perim=%.1f\n",
self.w, self.h, self.area(), self.perimeter());
}
}
Rect r = Rect { w: 4.0, h: 3.0 };
r.print();
Output: Rect(4.0 x 3.0) area=12.0 perim=14.0
5.4 Fixed-size array fields
struct Packet {
uint8[256] data;
int length;
}
Packet pkt; pkt.data[0] = 0xDE; pkt.data[1] = 0xAD; pkt.length = 2;
Arrays work the same as local variables, not just struct fields. A brace list initialises one in place; as in C, a short list zero-fills the rest and {} zeroes the whole array, while an over-long list is a compile error:
int[3] a = {10, 20, 30}; // 10, 20, 30
int[4] b = {7, 8}; // 7, 8, 0, 0 (rest zero-filled)
int[3] c = {}; // 0, 0, 0
Arrays can be multidimensional. T[N][M] is N arrays of M, in C order: the leftmost bracket is the outer dimension, so a[i] is a row and a[i][j] is an element. Each index is bounds-checked against its own dimension, and a nested brace list initialises it (zero-filling at every level):
int[2][3] grid = { {1, 2, 3}, {4, 5, 6} };
int[2][2] part = { {7, 8} }; // second row zero-filled
printf("%d %d %d\n", grid[0][0], grid[1][2], part[1][0]); // 1 6 0
Output: 1 6 0
5.4b Slices
A fixed array knows its size at compile time, but a function that takes one has to be told the length separately, the classic C footgun where the pointer and its count drift apart. A slice, written T[] (empty brackets), fixes that: it is a fat pointer carrying both the data pointer and the length, so the length always travels with the data.
You make a slice by slicing an array with a half-open range (the same .. used by for-in). You can slice a raw pointer too, so a heap buffer from alloc<T>(n) becomes a slice: ptr[lo..hi] is a T[] over that memory. Either way the slice is a view: it aliases the backing storage, so writing through it writes to the array or buffer.
int[6] a = {10, 20, 30, 40, 50, 60};
int[] mid = a[1..4]; // view of {20, 30, 40}
mid[1] = 99; // writes through: a[2] is now 99
printf("%lld %d\n", mid.len, a[2]); // 3 99
Output: 3 99
A slice supports indexing (s[i]), its length via s.len, iteration with for (x in s), and is passed by value (the pointer + length are copied; the backing storage is shared). A function that sums any run of ints needs no count argument:
int sum(int[] s) {
int total = 0;
for (x in s) { total = total + x; }
return total;
}
sum(a[0..6]); // the whole array as a slice
5.5 union
A union looks like a struct but all fields share the same memory location at offset 0. The union's size equals the size of its largest field. Writing one field and reading another reinterprets the underlying bits:
union Value {
int i;
float f;
*uint8 p;
}
let v: Value;
v.i = 0x3F800000; // bit pattern for 1.0f
printf("%f\n", v.f); // 1.000000
Output: 1.000000
5.6 Bitfields
A bitfield is a struct field with a width in bits written after a colon. Multiple bitfields pack into the same underlying integer word. This lets you represent hardware registers, protocol headers, or compact flags without manual bit manipulation:
struct Flags {
uint32 a : 1; // 1-bit field: values 0 or 1
uint32 b : 3; // 3-bit field: values 0-7
uint32 c : 4; // 4-bit field: values 0-15
int tag; // normal field: mixed freely
}
Assignment and access use the same dot syntax as normal fields. The compiler generates read-modify-write automatically, updating one field never disturbs its neighbours:
Flags f;
f.a = 1; f.b = 5; f.c = 9; f.tag = 42;
printf("%d %d %d %d\n", f.a, f.b, f.c, f.tag); // 1 5 9 42
f.b = 2; // a and c are untouched
printf("%d %d %d\n", f.a, f.b, f.c); // 1 2 9
Output: 1 5 9 42 1 2 9
Struct literals work with bitfields the same as with normal fields:
Flags g = Flags { a: 1, b: 7, c: 15 };
printf("%d %d %d\n", g.a, g.b, g.c); // 1 7 15
5.7 packed struct
By default the compiler inserts padding between struct fields to satisfy alignment requirements. A uint8 followed by a uint32 occupies 8 bytes, not 5, because the compiler adds 3 padding bytes before the uint32. The packed qualifier removes all padding, giving you the exact on-wire layout. This is essential for hardware register maps, network protocols, and interop with C structs declared with __attribute__((packed)):
struct Natural { // default layout
uint8 tag; // 1 byte + 3 bytes padding
uint32 value; // 4 bytes
} // sizeof = 8
packed struct Wire { // packed keyword
uint8 tag; // 1 byte, no padding
uint32 value; // 4 bytes immediately after
} // sizeof = 5
printf("%lld\n", sizeof(Natural)); // 8
printf("%lld\n", sizeof(Wire)); // 5
Output: 8 5
You can also apply packing to a group of structs using #pragma pack, covered in Chapter 12.5. Any struct declared while pack(1) is active is packed, regardless of whether packed appears on the struct itself.
Pointers and Memory
A pointer is a variable that holds a memory address. Instead of containing a value directly, it points to where a value lives. Pointers are how Eskiu shares data between functions, manages heap memory, and interfaces with hardware. They are the most important concept in systems programming, and the one that trips up beginners most reliably. Take your time with this chapter.
6.1 Stack and heap
The stack is where local variables live. Fast, automatic, and limited in size. When a function returns, all its locals are reclaimed instantly.
The heap is a large pool of memory you manage yourself. Request a piece with alloc, use it as long as you need, return it with free. Heap allocations outlive the function that created them.
6.2 Address-of and dereference
int x = 42;
*int ptr = &x; // ptr holds the address of x
printf("%d\n", *ptr); // 42
*ptr = 100; // write through the pointer
printf("%d\n", x); // 100
Output: 42 100
6.3 Passing pointers to functions
This is the primary use of pointers in everyday code. A function that receives a pointer can modify the caller's variable:
void set_to_zero(*int p) { *p = 0; }
int x = 99;
set_to_zero(&x);
printf("%d\n", x); // 0
6.4 Heap allocation
Heap allocation is provided by the standard library module <mem>, not the language core. alloc<T>(n) allocates n elements of type T and returns a *T. free(p) releases it:
import <mem>;
*int arr = alloc<int>(8);
for (int i = 0; i < 8; i += 1) {
arr[i] = i * i;
}
printf("%d %d %d\n", arr[0], arr[1], arr[4]); // 0 1 16
free((*void)arr);
6.5 The golden rule
Every alloc must be paired with exactly one free. Not zero. Not two. One.
Warning Forgetting free is a memory leak. Freeing twice is a double-free. Using memory after free is use-after-free. All three are undefined behaviour. Chapter 18 covers each in detail.
6.6 Typed pointer arithmetic
Adding an integer to a pointer advances it by n × sizeof(T) bytes, not n bytes. ptr + 1 moves to the next element, regardless of element size:
*int arr = alloc<int>(4); arr[0]=10; arr[1]=20; arr[2]=30; arr[3]=40;
*int p = arr + 2; // advances 2 × sizeof(int) = 8 bytes
printf("%d\n", *p); // 30
free(arr);
Exception: *void and *char always use byte stride (1 byte per step), for C interop compatibility.
6.7 Null and checked nullable pointers
A plain *T may hold null, exactly as in C. Dereferencing a null pointer is undefined, so you guard it by hand:
*int p = null;
if (p != null) { printf("%d", *p); } // checked by hand
When you want the compiler to enforce that check, declare the pointer ?*T, a checked nullable pointer. You cannot dereference, index, or take a member of a ?*T until you have proven it non-null; inside an if (q != null) block the compiler narrows it to non-null and the dereference is allowed:
?*int q = maybe();
// *q; // compile error: q may be null; check it first
if (q != null) {
printf("%d", *q); // ok: narrowed to non-null here
}
A non-null *T converts to ?*T freely (it is always safe to forget non-nullness); going the other way requires a null-check. ?*T has the same representation as a bare pointer, so the safety is entirely at compile time with no runtime cost. Use ?*T at the boundaries where null is a real possibility (a lookup that can miss, an optional field) and keep *T where the value is always present.
6.8 Pointer constness
const T* is a pointer to read-only data: you can read through it and rebind it, but cannot write through it. T* const is a read-only pointer: you can write through it but cannot rebind it. Adding const is always safe; removing it is a compile error:
int x = 10; int y = 20; const int* r = &x; // pointer to const int // r[0] = 99; // compile error: write through const pointer r = &y; // ok: rebinding is allowed
int sum(const int* p, int n) { // caller's data is read-only
int s = 0;
for (i in 0..n) { s = s + p[i]; }
return s;
}
Use const T* parameters to communicate that a function will not modify the caller's data. It is a contract enforced by the compiler.
6.9 volatile
The volatile qualifier prevents the compiler from optimising away, caching, or reordering loads and stores to a pointer. Required for memory-mapped hardware registers, whose value can change independently of your program:
volatile let uart_dr: *uint8 = (uint8*) 0x09000000; *uart_dr = 'A'; // always emitted: never optimised away
volatile let status: *uint8 = (uint8*) 0x09000018;
while ((*status & 0x20) == 0) {} // re-reads the register every iteration
Interfaces
An interface describes what a type can do, not what it is. It names a set of methods that any conforming type must provide. Code written against an interface works with any type that satisfies it: today's types and types not yet written. You can add a type years later and old code will accept it, unchanged.
7.1 Declaring an interface
interface Shape {
float area();
void describe();
}
Any struct that provides both methods satisfies Shape. No explicit declaration needed. This is called structural typing.
7.2 Two structs, one interface
struct Circle {
float radius;
float area() { return self.radius * self.radius * 3.14159; }
void describe() {
printf("Circle r=%.2f area=%.2f\n", self.radius, self.area());
}
}
struct Rectangle {
float w;
float h;
float area() { return self.w * self.h; }
void describe() {
printf("Rect %.1fx%.1f area=%.2f\n", self.w, self.h, self.area());
}
}
7.3 Using the interface
void printShape(Shape s) { s.describe(); }
float totalArea(Shape a, Shape b) { return a.area() + b.area(); }
int main() {
Circle c = Circle { radius: 5.0 };
Rectangle r = Rectangle { w: 4.0, h: 6.0 };
printShape(&c);
printShape(&r);
printf("total: %.2f\n", totalArea(&c, &r));
return 0;
}
Output: Circle r=5.00 area=78.54 Rect 4.0x6.0 area=24.00 total: 102.54
Templates
A template is a blueprint the compiler fills in with a concrete type when you use it. You write the logic once, parameterised by a type placeholder, and the compiler generates a separate, fully optimised version for each type you actually use. There is no runtime overhead: templates are entirely a compile-time mechanism.
8.1 A template function
Type parameters in angle brackets. The return type can also be T:
T identity<T>(T x) { return x; }
T max<T>(T a, T b) {
if (a > b) return a;
return b;
}
int same = identity<int>(42); // 42
int big = max<int>(10, 20); // 20
float fbig = max<float>(1.5, 2.5); // 2.5
printf("%d %d %.1f\n", same, big, fbig);
Output: 42 20 2.5
When a type parameter appears directly as a parameter type, the compiler can infer it from the arguments; the angle brackets become optional:
printf("%d\n", max(3, 5)); // 5 : T inferred as int
printf("%d\n", identity(42)); // 42
8.2 A template struct
struct Pair<A, B> {
A first;
B second;
}
Pair<int, string> p = Pair<int, string> { first: 1, second: "one" };
printf("%d %s\n", p.first, p.second); // 1 one
8.3 Result
The standard way to return a value or an error. Import from the standard library:
import <result>;
Result<int, string> divide(int a, int b) {
if (b == 0)
return Err<int, string>("division by zero");
return Ok<int, string>(a / b);
}
let r1: Result<int, string> = divide(10, 2);
if (r1.ok == 1) { printf("10 / 2 = %d\n", r1.value); }
let r2: Result<int, string> = divide(7, 0);
if (r2.ok == 0) { printf("error: %s\n", r2.error); }
Output: 10 / 2 = 5 error: division by zero
8.4 Bounded type parameters (constraints)
A type parameter may carry one or more interface constraints, written after a colon: T f<T: Iface>(...). The constraint requires that every concrete type argument satisfy the named interface. Multiple constraints use +: <T: A + B>. The check happens at the call site, not deep in codegen, so the error message names the constraint that failed, not an obscure method-missing error:
interface Ord { int cmp(Ord* o); }
T pick<T: Ord>(T* a, T* b) {
if (a.cmp(b) < 0) { return a[0]; }
return b[0];
}
struct Box<T: Ord> { T item; }
struct Num {
int v;
int cmp(Num* o) {
if (self.v < o.v) { return -1; }
return 1;
}
}
// Num satisfies Ord: it has cmp. int does not. let x: Num; x.v = 3; let y: Num; y.v = 7; let lo: Num = pick<Num>(&x, &y); // lo.v == 3 let b: Box<Num>; b.item.v = 42;
Multiple constraints separate with +:
interface Show { int show(); }
interface Eq { int eq(Eq* o); }
int dump<T: Show + Eq>(T* x) { return x.show(); }
Structs satisfy a constraint by defining its methods. Primitives such as int have no methods. Instead they satisfy a constraint via a free function named like the interface method, with the primitive as the first parameter. Inside the generic body, a constrained call t.cmp(x) on a primitive t lowers to cmp(t, x):
interface Ord { int cmp(Ord* o); }
// Free function makes int satisfy Ord:
int cmp(int a, int b) { if (a < b) { return -1; } return 1; }
T pick<T: Ord>(T a, T b) {
if (a.cmp(b) < 0) { return a; } // lowers to cmp(a, b)
return b;
}
printf("%d\n", pick<int>(3, 7)); // 3
printf("%d\n", pick<Num>(&x, &y)); // also works
The function-pointer HashMap<K,V> from <map> remains useful when you want to thread hash/eq explicitly without declaring an interface. Scalar primitives only; pointer types are not yet supported.
Closures and Lambdas
A lambda is a function without a name. You can store it in a variable, pass it to another function, or write it inline. A closure is a lambda that remembers variables from the scope where it was created: it closes over them, carrying its own small piece of state.
9.1 Your first lambda
let double_it: fn(int)->int = int(int x) { return x * 2; };
printf("%d\n", double_it(5)); // 10
printf("%d\n", double_it(21)); // 42
fn(int)->int means: a function that takes one integer and returns one integer.
9.2 Higher-order functions
int apply(fn(int)->int f, int x) { return f(x); }
let square: fn(int)->int = int(int n) { return n * n; };
printf("%d\n", apply(square, 6)); // 36
printf("%d\n", apply(int(int x) { return x + 1; }, 9)); // 10
9.3 Closures
A lambda that references outer variables captures them by value at the moment of creation. Changing the original afterwards does not affect the closure:
int base = 10;
let add_base: fn(int)->int = int(int x) { return x + base; };
printf("%d\n", add_base(5)); // 15
printf("%d\n", add_base(32)); // 42
base = 999; // does not affect the closure
printf("%d\n", add_base(0)); // still 10
Output: 15 42 10
9.4 Void lambdas
let greet: fn()->void = void() {
printf("hello from a lambda\n");
};
greet();
9.5 Function pointers as return types and struct fields
A function can return a fn(T)->R value and a struct can hold one as a field:
fn(int)->int adder(int base) {
return int(int x) { return x + base; }; // returns a lambda
}
let add10: fn(int)->int = adder(10);
printf("%d\n", add10(5)); // 15
// Struct with a fn field: call directly
struct Ops { fn(int,int)->int op; }
int add(int a, int b) { return a + b; }
let o: Ops; o.op = add;
printf("%d\n", o.op(3, 4)); // 7
9.5 Escaping closures
By default a closure environment lives on the stack. Zero allocation cost, but it cannot outlive the function that created it. When a closure must outlive its creator the parameter that receives it must be marked escaping:
void on_ready(int fd, escaping fn(int)->void cb) {
handlers[fd] = cb; // stored: parameter must be escaping
}
int x = 42;
on_ready(fd, void(int n) { printf("%d %d\n", x, n); });
// free_closure(cb) when done
The escaping qualifier states, and the compiler enforces, that the closure may outlive its creator. Pass a non-escaping closure to an escaping parameter and you get a compile error.
9.7 Closures inside generic functions
A lambda declared inside a generic function body captures correctly across all instantiations. The closure environment is monomorphized alongside the outer template: a field typed by the type parameter gets the concrete type at each instantiation:
int call_it(escaping fn()->int op) { return op(); }
// box<T> captures a T-typed value and an int* in its closure
int box<T>(T v, int bump) {
*int hits = alloc<int>(1); *hits = 0;
let f: fn()->int = int() {
*hits = *hits + bump;
return (int)v + *hits;
};
int r1 = call_it(f); // v + bump
int r2 = call_it(f); // v + 2*bump
free_closure(f);
free((*void)hits);
return r1 + r2;
}
printf("%d\n", box<int>(10, 1)); // 23 (11+12)
Error Handling
Programs fail. Files do not exist. Numbers fall outside valid ranges. Network connections drop mid-request. Good error handling makes failures visible and recoverable rather than silent and catastrophic. Eskiu provides two tools: Result for expected failures, and try/catch for exceptional ones.
10.1 Result
import <result>;
Result<int, string> safeSqrt(int n) {
if (n < 0)
return Err<int, string>("negative input");
int i = 0;
while (i * i <= n) { i = i + 1; }
return Ok<int, string>(i - 1);
}
let r1: Result<int, string> = safeSqrt(25);
if (r1.ok == 1) { printf("sqrt(25) ~ %d\n", r1.value); }
let r2: Result<int, string> = safeSqrt(-4);
if (r2.ok == 0) { printf("error: %s\n", r2.error); }
Output: sqrt(25) ~ 5 error: negative input
10.2 try / catch / finally
int divide(int a, int b) {
if (b == 0) throw "division by zero";
return a / b;
}
try {
int r = divide(10, 0);
printf("result: %d\n", r); // never reached
} catch (string e) {
printf("caught: %s\n", e); // division by zero
} finally {
printf("done\n"); // always runs
}
Output: caught: division by zero done
Link with -lc++ (macOS) or -lstdc++ (Linux) when using exceptions.
10.3 The ? operator
Chaining multiple Result-returning calls with explicit if (r.ok == 0) return Err checks gets verbose quickly. The postfix ? operator shortens this: applied to a Result<T,E> expression, it either unwraps the value on success or immediately returns the Err to the caller:
import <result>;
Result<int, string> divide(int a, int b) {
if (b == 0) return Err<int, string>("division by zero");
return Ok<int, string>(a / b);
}
// Without ?, verbose
Result<int, string> compute_verbose(int a, int b, int c) {
let r1: Result<int, string> = divide(a, b);
if (r1.ok == 0) return Err<int, string>(r1.error);
let r2: Result<int, string> = divide(r1.value, c);
if (r2.ok == 0) return Err<int, string>(r2.error);
return Ok<int, string>(r2.value + 1);
}
// With ?, concise
Result<int, string> compute(int a, int b, int c) {
let x: int = divide(a, b)?; // unwrap or return Err
let y: int = divide(x, c)?;
return Ok<int, string>(y + 1);
}
The two functions are semantically identical. expr? on a Result<T,E> is syntactic sugar for the explicit check-and-return pattern. The type checker enforces that ? is only used inside a function that returns a compatible Result<T,E>.
This postfix ? is distinct from the ternary cond ? a : b (see 12.2b). The parser tells them apart by the :: a ? with a matching : ahead is a ternary, otherwise it is Result propagation. To propagate inside a ternary arm, parenthesise it: cond ? (divide(a, b)?) : fallback.
let good: Result<int, string> = compute(100, 5, 2);
if (good.ok == 1) { printf("ok=%d\n", good.value); } // ok=11
let bad: Result<int, string> = compute(100, 0, 2);
if (bad.ok == 0) { printf("err=%s\n", bad.error); } // err=division by zero
Output: ok=11 err=division by zero
10.4 When to use which
Use Result for expected, recoverable failures: file not found, invalid input, network timeout.
Use ? to propagate errors up the call stack without boilerplate.
Use throw for bugs and impossible states: an assertion failed, a data structure is internally inconsistent.
Concurrency
A thread is an independent sequence of execution. Your main function runs in one. Additional threads run in parallel, on separate CPU cores if available. Eskiu exposes threads as language primitives: thread_create and thread_join are keywords, not library calls.
11.1 thread_create and thread_join
int main() {
*void t = thread_create(void() {
printf("hello from the thread\n");
});
printf("hello from main\n");
thread_join(t);
return 0;
}
The two lines may print in either order. thread_join blocks until the thread has finished. On Linux, link with -lpthread.
11.2 Passing data via closures
Closures capture by value: each thread gets an independent copy, no shared state, no data race:
int id1 = 1; int id2 = 2;
let w1: fn()->void = void() { printf("thread %d\n", id1); };
let w2: fn()->void = void() { printf("thread %d\n", id2); };
*void t1 = thread_create(w1); *void t2 = thread_create(w2); thread_join(t1); thread_join(t2);
11.3 Shared state
When threads share heap-allocated data through a pointer, you must coordinate access. Without synchronisation, concurrent reads and writes produce corrupted results: a data race.
Warning Shared heap memory from multiple threads without synchronisation is undefined behaviour. Prefer passing data by value through closures.
11.3 Function decay
A named top-level function used as a value decays to a fn()->void pointer automatically. You can pass it directly to thread_create or any other higher-order function without wrapping it in a lambda:
void echo_server() {
// no captures needed
int fd = net_tcp_listen(54331);
// ...
}
// Pass function name directly, no void() { echo_server(); } wrapper
*void t = thread_create(echo_server);
thread_join(t);
The compiler synthesises a thin thunk that ignores the environment pointer and forwards to the named function. Function decay works with any fn(T,...)->R context, not just thread_create.
Function decay applies to named top-level functions only. Methods and local functions do not decay.
Operators
Eskiu has the operators you would expect from a systems language, and most will be familiar. The one that deserves attention is sizeof, a compile-time query that becomes essential when working with memory directly.
12.1 Arithmetic
int a = 17 + 5; // 22 int b = 17 - 5; // 12 int c = 17 * 5; // 85 int d = 17 / 5; // 3 : integer division truncates toward zero int e = 17 % 5; // 2 : remainder
12.1b Increment and decrement
The ++ and -- operators add or subtract one in place, on an integer or pointer variable. In prefix position the expression is the new value; in postfix position it is the old value. On a pointer they step by one element, like pointer arithmetic:
int i = 5;
int a = i++; // a = 5, then i = 6 (postfix: old value)
int b = ++i; // i = 7, then b = 7 (prefix: new value)
printf("%d %d %d\n", a, b, i); // 5 7 7
Output: 5 7 7
12.2 Bitwise
uint8 x = 0xB3; // 10110011 uint8 a = x & 0x0F; // 00000011 = 3 AND: keep lower nibble uint8 b = x | 0x0F; // 10111111 = 191 OR: set lower nibble uint8 c = x ^ 0xFF; // 01001100 = 76 XOR: flip all bits uint8 d = ~x; // 76 NOT uint8 e = x << 1; // 01100110 = 102 left shift uint8 f = x >> 2; // 00101100 = 44 right shift
Each bitwise operator has a compound-assignment form too, alongside the arithmetic += -= *= /= %= from Chapter 4:
int flags = 0; flags |= 0x04; // set bit 2 flags &= ~0x04; // clear bit 2 flags ^= 0x01; // toggle bit 0 flags <<= 1; // shift left flags >>= 2; // shift right
All six operators have compound assignment forms:
int x = 240; x >>= 2; // 60 x <<= 1; // 120 x &= 15; // 8 x |= 1; // 9 x ^= 3; // 10
12.2b Conditional (ternary)
The ternary cond ? a : b chooses between two values. It evaluates cond, then evaluates and yields exactly one of the arms, so side effects in the unused arm never run. The two arms take a common type (two numerics promote C-style). It is often the concise form of a small if/else:
int a = 5; int b = 9; int m = a > b ? a : b; // max: 9
It is right-associative, so a chain reads like an if/else ladder:
int score = 85;
char grade = score >= 90 ? 'A'
: score >= 80 ? 'B'
: score >= 70 ? 'C' : 'F'; // 'B'
Eskiu also uses ? as the postfix Result-propagation operator (Chapter 10). The two never collide: a ? with a matching : ahead is a ternary; otherwise it propagates. To propagate inside a ternary arm, wrap it in parentheses: cond ? (may_fail()?) : fallback.
12.3 sizeof
sizeof(T) returns the number of bytes a value of type T occupies in memory. The result is a compile-time int64 constant: no runtime code is generated. Print it with %lld:
printf("%lld\n", sizeof(int)); // 4
printf("%lld\n", sizeof(int64)); // 8
printf("%lld\n", sizeof(double)); // 8
struct Vec3 { float x; float y; float z; }
printf("%lld\n", sizeof(Vec3)); // 12
12.4 Operator precedence
From lowest to highest. When in doubt, add parentheses:
= += -= *= /= %= assignment (right to left) ?: ternary (right to left) || && | (bitwise OR) ^ & (bitwise AND) == != < > <= >= << >> + - * / % ! - ~ & * ++ -- (TYPE) unary (right to left) () [] . ? call, index, member, propagate
12.4b Range operator
The .. operator produces a half-open integer range for use in for-in loops. A..B iterates A, A+1, …, B-1. Both bounds are any integer expression; an empty range (A ≥ B) runs zero times:
for (i in 0..5) { /* 0 1 2 3 4 */ }
for (i in lo..hi){ /* lo to hi-1 */ }
for (i in 5..5) { /* never runs */ }
.. is only meaningful inside for (x in A..B). It is not a standalone expression and has no value outside a for-in head.
12.5 Preprocessor
Eskiu has a preprocessor that runs before the lexer. It supports object-like macros, function-like macros with parameter substitution, recursive expansion, and conditional compilation. Macros are shared across all files in a multi-file build.
Object-like macros substitute a name with a value everywhere it appears:
#define MAX 100 #define GREETING "hello" #define DEBUG
printf("%d\n", MAX); // 100
printf("%s\n", GREETING); // hello
printf("%d\n", MAX * 2); // 200
Output: 100 hello 200
Function-like macros take parameters. The call must fit on one line:
#define SQ(x) ((x) * (x)) #define DOUBLE(n) ((n) + (n)) #define LIMIT (MAX + 1) // macro expanding another macro
printf("%d\n", SQ(5)); // 25
printf("%d\n", DOUBLE(SQ(3))); // 18
printf("%d\n", LIMIT); // 101
Output: 25 18 101
Conditional compilation includes or excludes blocks at compile time:
#ifdef DEBUG
printf("debug\n");
#else
printf("release\n");
#endif
#undef DEBUG
#ifdef DEBUG
printf("still debug\n");
#else
printf("debug off\n");
#endif
Line numbers are preserved through macro expansion: error messages always point to the original source location.
A macro body can span multiple physical lines using a backslash continuation. Each consumed newline is replaced by a blank line so error messages still point to the right source location:
#define POLY(x) \
((x) * (x) \
+ 2 * (x) \
+ 1)
printf("%d\n", POLY(3)); // 16
printf("%d\n", POLY(0)); // 1
Output: 16 1
#pragma pack controls struct padding for all structs declared while it is active. Use push and pop to save and restore the current packing:
#pragma pack(push, 1) // save current packing, switch to 1-byte
struct Wire {
uint8 tag; // no padding inserted
uint32 value;
} // sizeof = 5
#pragma pack(pop) // restore previous packing
struct Natural {
uint8 tag; // 3 bytes padding before value
uint32 value;
} // sizeof = 8
printf("%lld %lld\n", sizeof(Wire), sizeof(Natural)); // 5 8
Output: 5 8
The compiler predefines one macro for the host OS: __APPLE__ on macOS, __linux__ on Linux. Use them to write portable code that compiles correctly on both platforms:
#ifdef __APPLE__
printf("macOS\n");
#else
printf("Linux\n");
#endif
This is how <net> selects the correct sockaddr_in layout: macOS has an extra sin_len field that Linux does not.
__FILE__ expands to the current source file path; __LINE__ expands to the current line number. #error aborts compilation with a message, honoring #ifdef branches:
printf("%d\n", __LINE__); // prints the line number
printf("%s\n", __FILE__); // prints the file path
#ifdef DEBUG #error DEBUG builds are not supported in this file #endif
Supported directives: #define (object-like and function-like, including multi-line with \), #undef, #ifdef, #ifndef, #else, #endif, #error, #pragma pack. Predefined: __APPLE__, __linux__, __FILE__, __LINE__, __ESKIU_FREESTANDING__. pack(N) with any N ≥ 1 is fully supported in v0.2.0.
Multi-file Programs
Every program in this book so far has lived in a single file. Real programs grow. When a file exceeds a few hundred lines, it becomes hard to navigate. The import statement lets you split code across as many files as you like.
13.1 Local file imports
// utils.esk
extern double sqrt(double x);
double hypotenuse(double a, double b) { return sqrt(a*a + b*b); }
// main.esk import "utils.esk"; extern int printf(string fmt, ...);
int main() {
printf("%.4f\n", hypotenuse(3.0, 4.0)); // 5.0000
return 0;
}
13.2 Standard library imports
import <result>; // Result<T,E>, Ok, Err import <list>; // List<T> import <string>; // String (mutable) import <math>; // sqrt, pow, floor, ceil import <io>; // printf, scanf import <mem>; // memcpy, memset, strlen import <fs>; // file I/O
13.3 No include guards
Each file is parsed exactly once per compilation, regardless of how many files import it. Circular imports are detected and handled.
13.4 Project layout
project/
main.esk # entry point
types.esk # shared struct definitions
extern.esk # all extern declarations
utils.esk # helpers
Makefile
# Makefile build: ■eskiuc main.esk -o project
# With libraries: ■eskiuc main.esk -o project -lpthread -lssl -lcrypto
C Interop
The entire C library ecosystem (OpenSSL, zlib, SQLite, libpng, the POSIX API) is available to Eskiu programs. Declare the function with extern, the compiler generates a standard C-ABI call, and the linker resolves it.
14.1 Declaring extern functions
extern int printf(string fmt, ...); extern int strlen(string s); extern *void malloc(int size); extern *void memcpy(*void dst, *void src, int n); extern void exit(int code);
14.2 Calling OpenSSL: a real example
extern *void EVP_CIPHER_CTX_new();
extern void EVP_CIPHER_CTX_free(*void ctx);
extern *void EVP_aes_256_cbc();
extern int EVP_DecryptInit(*void ctx, *void cipher,
*uint8 key, *uint8 iv);
extern int EVP_DecryptUpdate(*void ctx, *uint8 out, *int outl,
*uint8 in, int inl);
extern int EVP_DecryptFinal(*void ctx, *uint8 out, *int outl);
14.3 Linking
All library flags are passed directly to eskiuc; it forwards them to the system linker:
eskiuc main.esk -o program -lssl -lcrypto # OpenSSL eskiuc main.esk -o program -lm # libm eskiuc main.esk -o program -lpthread # pthreads (Linux) eskiuc main.esk -o program -lc++ # C++ exceptions (macOS) eskiuc main.esk -o program -lstdc++ # C++ exceptions (Linux) eskiuc main.esk -o program -L/my/libs -lmylib # custom path
To compile to an object file and link separately:
eskiuc main.esk -c -o main.o clang main.o other.o -lssl -o program
14.4 Passing Eskiu functions as C callbacks
Many C APIs take a raw function pointer (qsort, signal, OpenSSL ALPN). Casting a top-level function to *void yields its bare C function-pointer address rather than the two-word closure fat pointer:
import <mem>; extern void qsort(*void base, int64 n, int64 size, *void compar);
int cmp(*void a, *void b) {
int x = *(*int)a; int y = *(*int)b;
if (x < y) return -1;
if (x > y) return 1;
return 0;
}
*int arr = alloc<int>(5); arr[0]=3; arr[1]=1; arr[2]=4; arr[3]=1; arr[4]=5; qsort((*void)arr, (int64)5, (int64)4, (*void)cmp); // (*void)cmp is the raw C pointer, not a fat closure free((*void)arr);
This works for top-level functions only. A closure or lambda still carries an environment and cannot be passed as a C callback.
The Standard Library
Eskiu's standard library ships as 35 plain .esk files with the compiler. There is no magic: they are Eskiu code, readable at lib/eskiu/stdlib/ in your installation. Import each module with an angle-bracket import.
15.1 result: errors as values
The most important module. Result<T,E> is a struct with three fields: ok (1 = success, 0 = failure), value (holds T on success), and error (holds E on failure). Two constructor functions create instances:
// result.esk: the complete interface
struct Result<T, E> {
int ok; // 1 = success, 0 = failure
T value;
E error;
}
Result<T, E> Ok<T, E>(T value) // wrap a success value Result<T, E> Err<T, E>(E error) // wrap an error value
import <result>;
Result<int, string> divide(int a, int b) {
if (b == 0) return Err<int, string>("division by zero");
return Ok<int, string>(a / b);
}
let r: Result<int, string> = divide(10, 2);
if (r.ok == 1) {
printf("result: %d\n", r.value);
} else {
printf("error: %s\n", r.error);
}
Output: result: 5
15.2 list: dynamic arrays
List<T> is a growable array. Capacity doubles when full. The struct layout is { *T data; int size; int cap; }. All fields are public.
// list.esk: the complete interface void List_init<T>(List<T>* self, int cap) // call before use void List_push<T>(List<T>* self, T item) // append an element T List_get<T>(List<T>* self, int i) // read element i int List_len<T>(List<T>* self) // returns self->size void List_free<T>(List<T>* self) // free the buffer
The compiler infers T from the List<T>* argument; no explicit type argument needed:
import <list>;
List<int> nums; List_init(&nums, 4); // T inferred as int List_push(&nums, 10); List_push(&nums, 20); List_push(&nums, 30);
printf("len: %d\n", List_len(&nums)); // 3
// for-in iterates a List directly
for (x in nums) {
printf("%d ", x);
}
printf("\n");
// Classic index loop also works
for (int i = 0; i < nums.size; i += 1) {
printf("%d ", nums.data[i]); // direct field access
}
printf("\n");
List_free(&nums);
Output: len: 3 10 20 30 10 20 30
15.3 string: mutable text
The built-in string type is an immutable C-string literal. For text you need to build or modify at runtime, use String, a struct with an owned, growable buffer. v0.2.0 brings a richer standard library. The complete String API:
// string.esk: the complete interface void String_init(String* self, int cap) // initialise with capacity void String_from(String* self, string s) // initialise from a literal void String_append(String* self, string s) // append a string literal void String_concat(String* self, String* other) // append another String void String_push(String* self, char c) // append a single character void String_clear(String* self) // reset to empty (keep buffer) void String_reverse(String* self) // reverse in place void String_free(String* self) // free the buffer
*char String_cstr(String* self) // null-terminated C string int String_len(String* self) // length in bytes char String_char_at(String* self, int i) // read character at index i void String_set(String* self, int i, char c) // overwrite character at i int String_index_of(String* self, char c) // first index of c, or -1 int String_eq(String* self, String* other) // 1 if equal, else 0 int String_eq_cstr(String* self, string s) // compare to C string literal void String_substring(String* self, String* out, int start, int count) void String_from_int(String* self, int n) // render integer as text int String_to_int(String* self) // parse leading integer int String_starts_with(String* self, string prefix) // 1 if prefix matches int String_ends_with(String* self, string suffix) // 1 if suffix matches void String_trim(String* self) // strip whitespace int String_next_token(String* self, char sep, *int pos, String* out) void String_split(String* self, char sep, List<String>* out) // split into list void String_split_free(List<String>* parts) // free parts from split
import <string>;
let s: String;
String_from(&s, "hello");
String_push(&s, '!'); // hello!
String_set(&s, 0, 'H'); // Hello!
printf("%s (len=%d)\n", String_cstr(&s), String_len(&s));
printf("%c\n", String_char_at(&s, 0)); // H
printf("%d\n", String_index_of(&s, 'l')); // 2
printf("%d\n", String_eq_cstr(&s, "Hello!")); // 1
let sub: String;
String_init(&sub, 8);
String_substring(&s, &sub, 1, 4); // ello
String_reverse(&sub);
printf("%s\n", String_cstr(&sub)); // olle
let num: String;
String_init(&num, 8);
String_from_int(&num, -2026);
printf("%s -> %d\n", String_cstr(&num), String_to_int(&num)); // -2026 -> -2026
String_free(&s); String_free(&sub); String_free(&num);
Output: Hello! (len=6) H 2 1 ello olle -2026 -> -2026
15.4 math: mathematical functions
Thin wrappers over libm. Link with -lm. All functions take and return double except abs:
// math.esk: the complete interface double sqrt(double x) // square root double fabs(double x) // absolute value (float) int abs(int x) // absolute value (int) double pow(double base, double exp) // base raised to exp double floor(double x) // round down double ceil(double x) // round up double fmod(double x, double y) // floating-point remainder
import <math>;
printf("sqrt(2) = %.6f\n", sqrt(2.0));
printf("pow(2, 10) = %.0f\n", pow(2.0, 10.0));
printf("floor(3.7) = %.0f\n", floor(3.7));
printf("ceil(3.2) = %.0f\n", ceil(3.2));
printf("fabs(-5.5) = %.1f\n", fabs(-5.5));
Output: sqrt(2) = 1.414214 pow(2, 10) = 1024 floor(3.7) = 3 ceil(3.2) = 4 fabs(-5.5) = 5.5
15.5 io: standard input/output
Wrappers for libc stdio. printf is the most common; you have been using it since Chapter 1:
// io.esk: the complete interface int printf(string fmt, ...) // print to stdout int fprintf(*void stream, string fmt, ...) // print to a file handle int sprintf(*char buf, string fmt, ...) // print to a buffer int scanf(string fmt, ...) // read from stdin int puts(string s) // print string + newline int getchar() // read one character int putchar(int c) // write one character
15.6 mem: memory operations
Wrappers for libc memory functions. Essential for working with raw byte buffers in systems code:
// mem.esk: the complete interface *void memcpy(*void dst, *void src, int n) // copy n bytes *void memset(*void dst, int c, int n) // fill n bytes with c *void memmove(*void dst, *void src, int n) // copy, handles overlap int memcmp(*void a, *void b, int n) // compare n bytes int strlen(string s) // length of C string *void memchr(*void s, int c, int n) // find byte in buffer
import <mem>;
*uint8 buf = alloc<uint8>(uint8)16);
memset(buf, 0, 16); // zero the buffer
memcpy(buf, "Hello", 5); // copy 5 bytes
printf("%d\n", memcmp(buf, "Hello", 5)); // 0 = equal
printf("%d\n", strlen("Eskiu")); // 5
free(buf);
Output: 0 5
15.7 fs: file I/O
fs wraps POSIX file operations. A file handle is a *void returned by fs_open. Always check for null before using the handle, and always close when done:
// fs.esk: the complete interface *void fs_open(string path, string mode) // "r", "w", "rb", "wb", "a" void fs_close(*void fp) // close the handle void fs_flush(*void fp) // flush write buffer
int64 fs_read(*void fp, *uint8 buf, int64 n) // read up to n bytes *uint8 fs_readline(*void fp, *uint8 buf, int n)// read one line int64 fs_write(*void fp, *uint8 buf, int64 n) // write n bytes int fs_puts(*void fp, string s) // write a string
int fs_seek(*void fp, int64 offset, int w) // w: 0=SET 1=CUR 2=END int64 fs_tell(*void fp) // current position int64 fs_size(*void fp) // file size in bytes
*uint8 fs_read_all(string path, *int64 out_len)// read entire file (caller frees) int fs_write_all(string path, *uint8 buf, int64 n) // write entire buffer
int fs_eof(*void fp) // 1 if at end of file int fs_error(*void fp) // 1 if error occurred
import <fs>;
// Write a file
*void f = fs_open("hello.txt", "w");
if (f != null) {
fs_puts(f, "Hello from Eskiu\n");
fs_close(f);
}
// Read entire file at once
int64 size = 0;
*uint8 data = fs_read_all("hello.txt", &size);
if (data != null) {
printf("read %lld bytes\n", size);
free(data);
}
Output: read 17 bytes
fs_read_all returns a heap-allocated buffer. You must free it when done.
15.8 map: string-keyed hash map
The <map> module provides Map<V>, a generic string-keyed hash map using open addressing with linear probing and 0.75 load-factor growth. Keys are strings; values are any type V:
import <map>;
let m: Map<int>; Map_init<int>(&m, 16); // initial capacity
// get-or-insert: returns *V slot, sets *created = 1 if new
int created = 0;
*int slot = Map_at<int>(&m, "hits", &created);
if (created == 1) { slot[0] = 0; }
slot[0] = slot[0] + 1;
// lookup only
int v = 0;
if (Map_get<int>(&m, "hits", &v) == 1) {
printf("%d\n", v);
}
Map_free<int>(&m);
Keys are copied on insert; the map owns them. Map_at returns a stable *V pointer into the value array, valid until the next insert that triggers a grow. For file uploads and binary payloads, pair with <multipart>:
import <multipart>; import <string>;
// Extract a named part from a multipart/form-data body
String ct; String_from_cstr(&ct, req.headers); // Content-Type header value
String boundary; String_init(&boundary, 64);
if (multipart_boundary(&ct, &boundary) == 1) {
*uint8 part; int64 plen;
if (multipart_part(body, body_len, &boundary,
"photo", &part, &plen) == 1) {
// part points into body (zero-copy slice), plen bytes
}
}
String_free(&boundary); String_free(&ct);
15.8b HashMap: generic key map
Map<V> keys on strings. HashMap<K,V> keys on any type: you pass hash and eq function pointers at init, the same pattern as C's qsort. Built-in helpers int_hash/int_eq cover integer keys:
import <map>;
let m: HashMap<int, int>; HashMap_init<int,int>(&m, 16, int_hash, int_eq);
int created = 0;
*int slot = HashMap_at<int,int>(&m, 42, &created);
if (created == 1) { slot[0] = 0; }
slot[0] += 1;
int v = 0;
if (HashMap_get<int,int>(&m, 42, &v) == 1) {
printf("%d\n", v);
}
HashMap_free<int,int>(&m);
15.9 bytes: binary-safe byte buffer
The <bytes> module provides Bytes: a growable binary-safe buffer backed by *uint8 and an explicit length. Unlike String, which is NUL-terminated, Bytes treats every byte as real data, essential for binary payloads and cryptographic output:
import <bytes>;
let b: Bytes; Bytes_init(&b, 64);
uint8 buf[4] = {0xDE, 0xAD, 0x00, 0xBE}; // embedded NUL is fine
Bytes_append_raw(&b, buf, 4);
Bytes view = Bytes_slice(&b, 0, 2); // non-owning, no alloc
Bytes enc = Bytes_to_base64(&b); Bytes_free(&enc); Bytes_free(&b);
Use HttpReq_body(&r;) to get a non-owning Bytes view of an HTTP request body without copying. A slice has cap = 0, so Bytes_free is safe to call on it.
15.10 random: seedable randomness
The <random> module is a seedable pseudo-random generator (xoshiro256**). All of its state lives in an Rng value, so there is no hidden global: a given seed always reproduces the same stream, which is exactly what you want for tests and repeatable runs. It is fast and passes the standard statistical tests, but it is not cryptographically secure, so do not use it for keys, tokens, or nonces.
import <random>;
let r: Rng; rng_seed(&r, 12345); // deterministic
uint64 x = rng_next(&r); // raw 64-bit int64 d = rng_range(&r, 1, 7); // a die: uniform in [1, 7) double u = rng_double(&r); // uniform in [0.0, 1.0) int b = rng_bool(&r); // 0 or 1
rng_range and rng_below are unbiased: they redraw whenever they land on the handful of values a plain modulo would skew, so every outcome is equally likely. rng_fill(&r, buf, n) fills a byte buffer. For a nondeterministic stream, seed from the clock: import <time>; rng_seed(&r, (uint64)time_now_ms());
15.11 regex: pattern matching
The <regex> module is a regular-expression engine built as a Thompson NFA, run as a Pike VM. It explores every path in lockstep, so matching is linear in the length of the input with no catastrophic backtracking: the pathological patterns that hang backtracking engines (the basis of a ReDoS attack) cannot occur here.
For a quick yes/no, regex_match compiles the pattern and searches the text for you:
import <regex>;
if (regex_match("\\d+-\\d+", "order 12-34") == 1) { /* found */ }
To reuse a pattern or read capture groups, compile once, then search and pull the groups out (group 0 is the whole match, group 1 the first parenthesized part, and so on):
Regex re = regex_compile("(\\w+)@(\\w+)");
let m: Match;
if (regex_search(&re, "user@host", &m) == 1) {
let name: String;
String_init(&name, 16);
match_group(&m, "user@host", 1, &name); // "user"
String_free(&name);
}
match_free(&m);
regex_free(&re);
The syntax covers literals, . (any byte but newline), character classes [a-z] and [^...], the shorthands \d \w \s (and \D \W \S), the quantifiers * + ? {m} {m,} {m,n}, alternation |, capturing groups ( ), and the anchors ^ and $. Quantifiers are greedy by default; append a ? (as in .+?) for the lazy form that matches as little as possible.
15.12 Additional stdlib: quick reference
// net: TCP sockets (see Chapter 23)
import <net>; net_tcp_listen net_accept net_tcp_connect
net_send net_recv net_close
// threading: Mutex / Cond / Sem over pthread import <threading>; Mutex Cond Sem (init/lock/unlock/wait/signal/destroy)
// http: HTTP/1.1 worker pool server import <http>; http_serve(port, workers, handler)
// json: builder + parser
import <json>; Json_init/obj_begin/key/str/int/obj_end/cstr/free
json_parse/JsonValue_get/JsonValue_as_double/JsonValue_free
// alloc: explicit allocators over a caller buffer (Zig model)
import <alloc>; Bump Arena Pool FirstFit
alloc_with(&allocator, T, n) -> *T
// sysheap: OS-backed heap (no libc malloc)
import <sysheap>; heap_init(&h, size) bool heap_alloc(&h, n) *void
heap_free(&h, p) heap_destroy(&h)
// time / env / base64 / path
import <time>; time_now_ms time_now_s time_monotonic_ms sleep_ms(ms)
DateTime time_to_utc/time_from_utc time_format_iso // UTC civil calendar
import <env>; env_get env_has env_get_or env_get_int
import <base64>; base64_encode/decode(*uint8 src, int n, *uint8 out)
base64_encoded_len(n) base64_decoded_len(n)
import <path>; path_join path_basename path_dirname path_extension
// sort: generic heapsort + binary search over a *T array (O(n log n) worst case)
import <sort>; sort<T>(a, n, cmp) bsearch<T>(a, n, key, cmp)
// cmp(&x, &y) returns <0 / 0 / >0, like C qsort; works on a List's .data too
// url: RFC 3986 percent-encoding + form-query lookup
import <url>; url_encode(s, &out) url_decode(s, &out)
url_query_get(query, key, &out) 1/0 // "a=1&msg=hi+there" -> "hi there"
// uuid: RFC 4122 version-4, built on(not crypto-secure) import <uuid>; let r: Rng; rng_seed(&r, seed); uuid_v4(&r, &out)
// async runtime import <atomic>; atomic_load atomic_store atomic_swap atomic_cas import <executor>; Executor (event loop + thread-safe queue) import <timer>; timer_after(lp, ms) -> *Future<int> import <futureval>; select2v -> Either<A,B> join2v -> Pair<A,B>
// HTTP/2 + TLS stack import <http2>; HTTP/2 framing + streams (RFC 7540) import <hpack>; HPACK header compression with Huffman (RFC 7541) import <http2_server>; http2_serve_async (same handler interface as <http>) import <tls>; OpenSSL TLS + ALPN h2 negotiation
// Data structures
import <map>; Map<V>: string-keyed hash map (open addressing, 0.75 load)
Map_init<V>(&m, cap) Map_at<V>(&m, key, &created) Map_get<V> Map_free<V>
// HTTP helpers
import <multipart>; multipart_boundary(ct, out) 1=ok
multipart_part(body, blen, boundary, name, &part_ptr, &part_len) -> int
Inline Assembly
Sometimes you need to speak to hardware directly: toggle a CPU flag, write to a memory-mapped register, execute a single privileged instruction. Eskiu's asm(...) statement embeds assembly inline, lowering directly to LLVM inline asm nodes.
16.1 Simple form
asm("nop"); // no operation
asm("wfi"); // ARM64: wait for interrupt
asm("cli"); // x86: disable interrupts
16.2 Extended form with inputs
GCC-compatible constraint syntax. "memory" as clobber prevents the compiler from reordering memory accesses across the instruction:
void uart_putc(int c) {
int64 UART_DR = 0x09000000;
asm("strb ${0:w}, [$1]" :: "r"(c), "r"(UART_DR) : "memory");
}
16.3 volatile: a reminder
Hardware registers accessed via inline assembly almost always need volatile (covered fully in
Chapter 6.8). Without it, the compiler may eliminate a store it considers redundant:
volatile let uart_dr: *uint8 = (uint8*) 0x09000000; *uart_dr = 'A'; // always emitted
Cross-compilation
Cross-compilation means building a binary for a different machine than the one you are compiling
on. You might develop on an x86-64 laptop but deploy to an ARM64 server, or write a kernel that runs on bare metal with no OS. Eskiu handles this with two flags.
17.1 --target
eskiuc program.esk --target x86_64-pc-linux-gnu -o prog.o
eskiuc kernel.esk --target aarch64-unknown-none-elf \
--freestanding -o kernel.o
Triple Use it for
x86_64-pc-linux-gnu Linux on Intel/AMD 64-bit
aarch64-unknown-linux-gnu Linux on ARM64 (Raspberry Pi, Graviton)
aarch64-unknown-none-elf Bare-metal ARM64, no OS, no libc
x86_64-unknown-none Bare-metal x86-64, no OS, no libc
17.2 --freestanding
When targeting bare-metal there is no libc. --freestanding redirects alloc and free to your own esk_alloc and esk_free:
// alloc.esk: bump allocator int64 _heap = 0x40300000; int64 _heap_end = 0x40400000;
*uint8 esk_alloc(int64 size) {
if (_heap + size > _heap_end) return null;
int64 ptr = _heap;
_heap = _heap + size;
return (*uint8) ptr;
}
void esk_free(*uint8 ptr) { /* bump: free is a no-op */ }
Bare-metal Programming
Bare-metal means no operating system, no libc, no runtime: just your code and the hardware. The CPU starts at a known address and executes whatever is there. You set up the stack. You initialise the serial port. You write every byte of output yourself. This chapter walks through the Eskiu kernel that ships in the repository: a real ARM64 kernel that boots in QEMU, prints to the serial console, and allocates heap memory, without a single line of C.
18.1 Why bare-metal matters
Most systems languages claim to support bare-metal. Eskiu does not claim it; it ships a working kernel. The fact that the same language that decodes AES-256-CBC ciphertext also boots on raw ARM64 hardware is not a coincidence. It is a consequence of three design decisions: no implicit runtime, alloc as a language primitive that can be redirected, and inline assembly as a first-class statement.
18.2 What changes in freestanding mode
When you compile with --freestanding, two things change:
First, alloc<T>(N) is now in <mem>. It no longer calls malloc from libc. Instead it calls esk_alloc, a function you provide. Similarly, free calls esk_free. The compiler inserts these calls; you write the implementations.
Second, the compiler targets a bare-metal ABI: no C runtime startup, no _start wrapper, no __libc_start_main. Your code is linked directly at the load address.
# Everything needed to cross-compile for bare-metal ARM64 eskiuc kernel.esk --target aarch64-unknown-none-elf --freestanding -o kernel.o
18.3 The memory map
QEMU's -M virt machine puts DRAM starting at 0x40000000. The linker script divides it into four regions:
/* linker.ld */ . = 0x40000000; /* kernel code loads here */
/* .text.boot: _start must be first */ /* .text / .rodata / .data / .bss */
. = 0x40200000; __stack_bottom = .; . += 0x10000; __stack_top = .; /* 64 KB stack */
/* 0x40300000: heap start (1 MB) */ /* 0x09000000: UART data register (MMIO, not in DRAM) */
The UART lives at 0x09000000, a memory-mapped I/O address separate from DRAM. Writing a byte there transmits it on the serial port.
18.4 The boot sequence
The CPU starts executing at _start in boot.s, the only assembly in the entire project. It does two things: sets up the stack pointer, then calls kernel_main. Everything after that is Eskiu:
// boot.s: ARM64 entry point
.global _start
.section .text.boot, "ax"
_start:
ldr x0, =__stack_top // load stack top address
mov sp, x0 // set stack pointer
bl kernel_main // jump to Eskiu
.hang:
b .hang // should never return
The linker script places .text.boot first, so _start lands at 0x40000000, exactly where QEMU starts executing.
18.5 The UART driver
With no OS and no libc, printing to the screen means writing bytes directly to the hardware register at 0x09000000. The PL011 UART on QEMU's virt machine transmits whatever byte you write there. This is the complete driver, 20 lines of Eskiu:
// uart.esk int64 UART_DR = 0x09000000;
void uart_putc(int c) {
asm("strb ${0:w}, [$1]" :: "r"(c), "r"(UART_DR) : "memory");
}
void uart_puts(string s) {
int i = 0;
while (s[i] != 0) {
uart_putc((int) s[i]);
i = i + 1;
}
}
void uart_puthex(int64 n) {
uart_puts("0x");
int shift = 60;
while (shift >= 0) {
int nibble = (int)((n >> shift) & 0xF);
if (nibble < 10) { uart_putc(nibble + 48); }
else { uart_putc(nibble - 10 + 65); }
shift = shift - 4;
}
}
uart_putc uses inline asm to execute strb (store byte) directly to the UART data register. The "memory" clobber prevents the compiler from reordering or eliminating the store. uart_puts walks the string one character at a time until it hits the null terminator. String indexing (s[i]) returns a char, the same feature used in Chapter 2.
18.6 The bump allocator
With --freestanding, every alloc<T>(N) call in your program becomes a call to esk_alloc. You provide the implementation. The kernel uses the simplest possible allocator, a bump allocator that advances a pointer through a 1 MB heap:
// alloc.esk int64 _heap_ptr = 0x40300000; // heap start int64 _heap_end = 0x40400000; // heap end (1 MB)
*uint8 esk_alloc(int64 size) {
if (_heap_ptr + size > _heap_end) { return null; }
int64 ptr = _heap_ptr;
_heap_ptr = _heap_ptr + size;
return (*uint8) ptr;
}
void esk_free(*uint8 ptr) {
// Bump allocator, free is a no-op.
// A real kernel would use a slab or buddy allocator here.
}
Allocation is a pointer advance and a bounds check. Deallocation is a no-op: memory is never reclaimed. This is appropriate for a demo kernel. A production kernel would replace these two functions with a slab allocator or buddy system; the rest of the kernel code stays exactly the same.
18.7 kernel_main
With the UART and allocator in place, kernel_main can print, allocate, and halt, all in Eskiu:
// kernel.esk import "uart.esk"; import "alloc.esk";
void kernel_main() {
uart_puts("Eskiu v0.1 kernel\r\n");
uart_puts("No libc. No runtime. Just Eskiu.\r\n");
uart_puts("UART base: "); uart_puthex(0x09000000); uart_puts("\r\n");
uart_puts("Heap start: "); uart_puthex(0x40300000); uart_puts("\r\n");
*uint8 buf = alloc<uint8>(uint8)64);
if (buf == null) {
uart_puts("alloc failed\r\n");
} else {
uart_puts("alloc(64): "); uart_puthex((int64) buf); uart_puts(" ok\r\n");
free(buf);
}
uart_puts("\r\nKernel halted.\r\n");
while (1) { asm("wfi"); } // wait for interrupt, idle loop
}
The while(1) { asm("wfi"); } at the end is the idle loop. wfi (wait for interrupt) is an ARM64 instruction that suspends the CPU until an interrupt arrives. Without it, the CPU would spin at full speed consuming power. With it, QEMU's CPU simulation goes idle.
18.8 Building and running
The Makefile handles the full build pipeline:
# Prerequisites (macOS) brew install lld qemu
# Build cd kernel && make
# Run in QEMU make run
Under the hood, make runs three commands:
# 1. Compile Eskiu to ARM64 object (no libc) eskiuc kernel.esk --target aarch64-unknown-none-elf --freestanding -o kernel.o
# 2. Assemble the boot stub clang --target=aarch64-unknown-none-elf -c boot.s -o boot.o
# 3. Link into a bare-metal ELF, no standard libraries ld.lld -T linker.ld -nostdlib -static -o kernel.elf boot.o kernel.o
QEMU loads the ELF, sets the program counter to 0x40000000, and starts executing. Expected output on the serial console:
Eskiu v0.1 kernel Running on QEMU -M virt (ARM64) No libc. No runtime. Just Eskiu.
UART base: 0x0000000009000000 Heap start: 0x0000000040300000 alloc(64): 0x0000000040300000 ok
Kernel halted.
Press Ctrl-A X to exit QEMU.
18.9 What this demonstrates
The kernel uses seven features from this book in combination: multi-file imports (Ch13), inline assembly (Ch16), cross-compilation (Ch17), volatile and MMIO (Ch6.8), pointer casting (Ch6.3), string indexing (Ch2.4), and the freestanding allocator hook. None of these features exist in isolation; bare-metal programming is where they compose.
The kernel is 130 lines of Eskiu and 7 lines of assembly. The assembly sets up the stack. Everything else (the UART driver, the allocator, the output, the idle loop) is plain Eskiu code that compiles to the same LLVM IR as any other Eskiu program.
Ownership and Resources
Eskiu has no garbage collector. No reference counter silently frees memory when you stop using it. You are responsible for every allocation, and that responsibility must be explicit in your code.
19.1 The ownership principle
Every heap allocation has exactly one owner. A function receiving a pointer does not own it. A function returning a heap pointer transfers ownership to the caller:
import <mem>;
// Receiver does not own
void process(*uint8 data, int len) {
// use data, do NOT free it here
}
// Caller must free the returned pointer
*uint8 build_packet(int size) {
return alloc<uint8>(size);
}
19.2 Struct ownership
When a struct holds a pointer field it owns what it points to. Pair every heap-owning struct with init and free functions:
import <mem>;
struct Buffer { *uint8 data; int len; int cap; }
void Buffer_init(Buffer* self, int cap) {
self.data = alloc<uint8>(cap);
self.len = 0; self.cap = cap;
}
void Buffer_free(Buffer* self) {
free((*void)self.data);
self.data = null; self.len = 0; self.cap = 0;
}
Setting self.data = null after free causes a null dereference on double-free instead of silent heap corruption.
19.3 Early return cleanup
Every early return must free everything allocated so far. Initialise all pointers to null, freeing null is always safe:
import <result>; import <mem>; extern int read_data(*uint8 buf); extern int transform(*uint8 src, *uint8 dst); extern void write_result(*uint8 out);
Result<int, string> process() {
*uint8 buf = null;
*uint8 out = null;
buf = alloc<uint8>(4096);
if (read_data(buf) != 0) {
free((*void)buf);
return Err<int, string>(\"read failed\");
}
out = alloc<uint8>(4096);
if (transform(buf, out) != 0) {
free((*void)buf); free((*void)out);
return Err<int, string>(\"transform failed\");
}
write_result(out);
free((*void)buf); free((*void)out);
return Ok<int, string>(1);
}
19.4 defer: automatic cleanup
Repeating every free on every exit path is exactly how leaks creep in: add a new early return and forget one. defer stmt; schedules a statement to run when the enclosing block is left (on fall-through, return, break, continue, or a propagated ? error). Write the cleanup once, next to the allocation, and it runs on every path out:
Result<int, string> process() {
*uint8 buf = alloc<uint8>(4096);
defer free((*void)buf); // freed however we leave
if (read_data(buf) != 0) { return Err<int, string>("read failed"); }
*uint8 out = alloc<uint8>(4096);
defer free((*void)out); // freed however we leave
if (transform(buf, out) != 0) { return Err<int, string>("transform failed"); }
write_result(out);
return Ok<int, string>(1);
}
The two versions do the same thing, but the second cannot leak: every exit, including a propagated ? error, runs both defers. Deferred statements run in LIFO order (here out is freed before buf), and a defer inside a loop body runs at the end of each iteration. A defer body may not return or break/continue out of itself.
Use defer for the common acquire-then-release pattern; reach for try/finally (10.2) when cleanup must also run as an exception unwinds past the scope.
errdefer is a variant that runs only on the error path: when the function exits through a propagated ? error, not on a normal return. It is exactly what you want for undoing half-finished work when a later step fails, while keeping it when everything succeeds:
Connection open_ready(string host) {
Connection c = connect(host)?;
errdefer close(c); // closed only if a later ? fails; kept on success
handshake(c)?; // if this errors, close(c) runs, then the Err propagates
return c; // success: c survives, errdefer does not run
}
On the error path a defer and an errdefer both run, LIFO. On the success path only the defer runs.
19.5 must_use: results you cannot ignore
Some return values are dangerous to drop. The result of alloc is the only handle to a heap block: discard it and the memory leaks immediately. Marking a function must_use makes the compiler reject any call whose result is thrown away:
must_use *uint8 grab() { return alloc<uint8>(64); }
grab(); // compile error: result of a must_use function is discarded
*uint8 p = grab(); // ok: the handle is kept
defer free((*void)p);
alloc in the standard library is already must_use, so a forgotten allocation is caught at compile time rather than leaking at run time. Apply must_use to your own functions that hand back an owned resource, an error code that must be inspected, or any value whose whole point is to be used.
Debugging
Systems programs fail in ways that higher-level languages protect you from. A null dereference crashes with a segfault. A buffer overrun silently corrupts memory three functions away from the bug. This chapter covers the tools that find these bugs quickly.
20.1 Reading a segfault
A segfault means the program accessed memory it was not allowed to. Run under the debugger to find the exact location:
eskiuc buggy.esk -o buggy
# macOS lldb ./buggy (lldb) run (lldb) bt # backtrace: call chain to the crash (lldb) frame 0 # inspect the crash frame, print locals
# Linux gdb ./buggy (gdb) run (gdb) bt (gdb) info locals
Frame 0 is where the crash happened. One of the locals will be null or pointing into freed memory.
20.2 AddressSanitizer
ASan catches use-after-free, buffer overruns, and double-frees with precise error messages and exact line numbers:
eskiuc buggy.esk --asan -o buggy_asan ./buggy_asan # ==ERROR: AddressSanitizer: heap-use-after-free # READ of size 4 at 0x602000000010 # #0 0x... in main buggy.esk:12
20.3 Memory leak detection
# Linux valgrind --leak-check=full ./prog
# macOS: LeakSanitizer is included in --asan eskiuc leaky.esk --asan -o prog_asan ./prog_asan
20.4 Inspecting generated IR
When output is wrong and the source looks correct, inspect what the compiler actually generated:
eskiuc suspect.esk --test-codegen # dumps LLVM IR to stdout # Verify that types, loads, and stores are what you expect
20.5 Bounds checking with --safe
By default array and slice indexing is unchecked, matching C: an out-of-range index reads or writes whatever is at that address. Compiling with --safe inserts a bounds check on every array and slice index, so a violation traps immediately instead of corrupting memory:
eskiuc prog.esk --safe -o prog ./prog # traps at the first out-of-bounds index
--safe is off by default, so release builds pay nothing for it. Turn it on during development and testing so silent memory corruption becomes a clean, immediate failure, then ship without it (or keep it on where a trap is preferable to undefined behaviour).
Testing
Eskiu has no built-in test framework. The language gives you functions, return values, and printf. That is all you need. The pattern described here is the one used by Eskiu's own 238-test suite.
21.1 Test functions
extern int printf(string fmt, ...);
int check(string name, int got, int want) {
if (got == want) { printf(\" PASS %s\n\", name); return 0; }
printf(\" FAIL %s: got %d want %d\n\", name, got, want);
return 1;
}
int add(int a, int b) { return a + b; }
int test_add() {
int f = 0;
f += check(\"2+3\", add(2, 3), 5);
f += check(\"0+0\", add(0, 0), 0);
f += check(\"neg\", add(-1, -2), -3);
return f;
}
int main() {
int failures = test_add();
if (failures == 0) printf(\"All passed.\n\");
else printf(\"%d failed.\n\", failures);
return failures;
}
Output: All passed.
21.2 The .esk + .expected pattern
Each test is a program that prints its output. A matching .expected file holds the correct output. A shell script compares them:
#!/bin/sh
PASS=0; FAIL=0
for esk in tests/*.esk; do
exp=\"${esk%.esk}.expected\"
[ -f \"$exp\" ] || continue
actual=$(eskiuc run \"$esk\" 2>&1)
if diff -q <(echo \"$actual\") \"$exp\" >/dev/null 2>&1; then
PASS=$((PASS+1))
else
echo \"FAIL $esk\"
diff <(echo \"$actual\") \"$exp\"
FAIL=$((FAIL+1))
fi
done
echo \"$PASS passed, $FAIL failed\"
eskiuc run compiles to a temp binary, runs it, and deletes it. No Makefile step needed for running individual tests.
Common Bugs
Systems programming bugs are in a class of their own. They do not always crash immediately. They corrupt memory silently, produce wrong results only under specific conditions, and sometimes disappear when you add a print statement. This chapter names the six most common bugs, shows what they look like in Eskiu, and tells you how to avoid them.
22.1 Use-after-free
You free memory and then access it. The memory may have been reused, so the read returns garbage or corrupts something else:
import <mem>;
*int p = alloc<int>(1);
*p = 42;
free((*void)p);
printf("%d\n", *p); // WRONG: p is no longer valid
Fix: Set the pointer to null after freeing. A null dereference crashes immediately and tells you where, instead of silently returning garbage:
free(p); p = null;
22.2 Double-free
You free the same pointer twice. The heap's internal structures are corrupted, and the program crashes somewhere unpredictable:
free(p); free(p); // WRONG
Fix: Same discipline, null after free. Freeing null is safe:
if (p != null) { free(p); p = null; }
22.3 Null dereference
You dereference a pointer that is null. This always crashes immediately with a segfault, one of the more honest bugs:
*int p = null;
printf("%d\n", *p); // WRONG: immediate crash
Fix: Check for null before any dereference:
if (p == null) { printf("no value\n"); return; }
printf("%d\n", *p);
22.4 Buffer overrun
You read or write past the end of an array. The memory beyond belongs to something else: another variable, heap metadata, or nothing. Writing past the end is a serious security vulnerability:
import <mem>;
*int arr = alloc<int>(5);
for (int i = 0; i <= 5; i += 1) { // WRONG: i == 5 is out of bounds
arr[i] = i;
}
Warning Buffer overwrite is the mechanism behind most memory corruption attacks.
Fix: The condition is i < 5, not i <= 5. Keep the size next to the pointer:
int n = 5;
*int arr = alloc<int>(n);
for (int i = 0; i < n; i += 1) { arr[i] = i; }
free((*void)arr);
22.5 Integer overflow
A calculation produces a value that does not fit in the type. Signed integer overflow wraps silently. The result is wrong, no error is raised:
int x = 2147483647; // INT_MAX
int y = x + 1; // WRONG: wraps to -2147483648
printf("%d\n", y); // -2147483648
Fix: Use int64 for large values:
int64 x = 2147483647; int64 y = x + 1; // 2147483648, correct
22.6 Memory leak
You allocate memory and never free it. The program runs fine at first, but over time consumes all available memory:
void process() {
*uint8 buf = alloc<uint8>(uint8)1024);
doWork(buf);
// WRONG: forgot to free buf
}
Fix: Every allocation has a corresponding free. The function that allocates owns the memory until it explicitly frees or transfers it:
void process() {
*uint8 buf = alloc<uint8>(uint8)1024);
doWork(buf);
free(buf); // correct
}
22.7 Bugs the compiler now catches
v0.2.2 closes several cases that previously reached codegen and produced bad IR or crashes at runtime:
// Duplicate switch case values, now a compile-time error:
// error: duplicate case value 1 in switch
switch (x) { case 1: ...; case 1: ...; }
// Negative array dimension, now rejected clearly: // error: array dimension must be positive int buf[-1];
// Dead code after block terminator, silently dropped:
int f() { return 1; printf("unreachable"); }
// the printf is removed without error (dead code)
The duplicate-case and negative-dimension checks were added by the compiler fuzzer introduced in v0.2.2, which mutates the test corpus and uses the LLVM IR verifier and sanitizers as oracles.
Networking
Network programming in Eskiu is POSIX sockets via the stdlib module. No framework required for basic use. A TCP server is a file descriptor you accept connections on. A TCP client is a file descriptor you write bytes to.
23.1 TCP echo server
import <net>; import <mem>; extern int printf(string fmt, ...);
int main() {
int fd = net_tcp_listen(7000);
if (fd < 0) { printf(\"bind failed\n\"); return 1; }
printf(\"listening on :7000\n\");
*uint8 buf = alloc<uint8>(1024);
while (1) {
int c = net_accept(fd);
if (c < 0) { continue; }
int64 n = net_recv(c, (*void)buf, 1024);
if (n > 0) { net_send(c, (*void)buf, n); }
net_close(c);
}
free((*void)buf); net_close(fd);
return 0;
}
# Test with netcat nc 127.0.0.1 7000 hello # type this hello # echoed back
23.2 Minimal HTTP server
import <net>; import <mem>; extern int printf(string fmt, ...);
int main() {
int fd = net_tcp_listen(8080);
printf(\"http://127.0.0.1:8080\n\");
string resp =
\"HTTP/1.1 200 OK\\r\n\"
\"Content-Type: text/plain\\r\n\"
\"Connection: close\\r\n\\r\n\"
\"Hello from Eskiu.\n\";
*uint8 req = alloc<uint8>(4096);
while (1) {
int c = net_accept(fd);
if (c < 0) { continue; }
net_recv(c, (*void)req, 4096);
net_send_str(c, resp);
net_close(c);
}
free((*void)req); net_close(fd);
return 0;
}
23.3 Concurrent connections
Spawn a thread per accepted fd. The fd is captured by value. Each thread has its own copy:
import <net>; import <mem>;
void handle(int c) {
*uint8 buf = alloc<uint8>(1024);
int64 n = net_recv(c, (*void)buf, 1024);
if (n > 0) { net_send(c, (*void)buf, n); }
net_close(c); free((*void)buf);
}
int main() {
int fd = net_tcp_listen(7001);
while (1) {
int c = net_accept(fd);
if (c < 0) { continue; }
thread_create(void() { handle(c); });
}
net_close(fd); return 0;
}
23.4 TCP client
import <net>; import <mem>; extern int printf(string fmt, ...);
int main() {
int fd = net_tcp_connect(\"127.0.0.1\", 7000);
if (fd < 0) { printf(\"connect failed\n\"); return 1; }
net_send_str(fd, \"ping\");
*uint8 buf = alloc<uint8>(256);
int64 n = net_recv(fd, (*void)buf, 256);
buf[n] = 0;
printf(\"got: %s\n\", (string)buf);
net_close(fd); free((*void)buf);
return 0;
}
Output (echo server running): got: ping
23.5 net_accept_addr and binary uploads
When you need the client IP address, use net_accept_addr instead of net_accept. For binary file uploads, HttpReq reads the complete body into a *uint8 buffer so binary bytes survive intact:
import <net>; import <http>; import <mem>;
void upload_handler(int fd) {
let r: HttpReq; HttpReq_init(&r);
if (http_recv(fd, &r, 10 * 1024 * 1024) != 0) {
http_reply_error(fd, 413, "", "body too large");
} else {
// r.body is *uint8 with r.body_len bytes, binary safe
String ct; String_init(&ct, 64);
String_from_cstr(&ct, HttpReq_header(&r, "Content-Type"));
String_free(&ct);
http_reply(fd, 200, "text/plain", "", null);
}
HttpReq_free(&r);
}
int main() {
int srv = net_tcp_listen(8081);
while (1) {
uint32 peer_ip = 0;
int c = net_accept_addr(srv, &peer_ip);
if (c < 0) { continue; }
upload_handler(c);
net_close(c);
}
net_close(srv); return 0;
}
Real World: INE QR Decoder
This chapter walks through the program that validated Eskiu: a production decoder for the cryptographic QR codes on Mexican voter ID cards. It uses nearly every feature covered in this book, and it runs 2.5× faster than the hand-written C reference it replaced.
19.1 Background
Every Mexican INE credential has two QR codes on the back. Together they encode the cardholder's biographical data and photo, encrypted with AES-256-CBC and RSA-8192. The encryption was reverse-engineered from a proprietary native ARM64 library (libPersonalCode.so,
4.6 MB, Chilkat 9.5 statically linked) and reimplemented entirely in Eskiu, calling OpenSSL via
extern.
19.2 The pipeline
Stage 1 (qr_extract.c, 12 lines of C): zxing-cpp extracts two 858-byte payloads from the QR codes. The only C code in the project.
Stage 2 (crypto.esk, 541 lines): six AES-256-CBC decryptions and one RSA-8192 modular exponentiation, all via OpenSSL extern calls. Manages intermediate buffers with alloc/free.
Stage 3 (output.esk, 186 lines): parses the pipe-delimited plaintext into 18 biographical fields and extracts the embedded WebP photo. Pure Eskiu, no extern.
19.3 Key data structures
// types.esk
struct QRPair {
uint8[858] left; // fixed-size, no heap allocation
uint8[858] right;
int ok;
char[256] err;
}
struct IneResult {
int ok;
char[256] err;
*char json; // heap-allocated by stage 3
int json_len;
*uint8 webp; // heap-allocated by stage 3
int webp_len;
double qr_ms;
double crypto_ms;
double decode_ms;
}
19.4 Performance
Stage Eskiu Reference C
QR extraction 71.7 ms 185.5 ms
Crypto pipeline 2.8 ms 2.9 ms
Output decode < 1 ms 0.5 ms
Total 74.4 ms 188.9 ms
2.5× faster than the reference C implementation on Apple Silicon. The crypto stage matches C within 0.1 ms. Both call the same OpenSSL functions. The speedup comes from the QR extraction stage, where LLVM's optimiser produces tighter code than the reference compiled without equivalent flags.
19.5 What it demonstrates
The decoder uses structs with fixed-size array fields, pointers and typed pointer arithmetic, heap allocation with explicit free, extern declarations for OpenSSL, Result for error propagation, multi-file imports, and string manipulation. Every feature in this book has a real use in that program.
The decoder ships in the Eskiu repository under ine_decoder/. Reading it is the best way to see how a real Eskiu program is structured.
Async and Await
Threads get concurrency by running code on multiple CPU cores at once. Async/await takes a different route: many tasks share a single thread, and each task suspends when it is waiting for I/O rather than blocking the thread. The result: a server that handles thousands of concurrent connections with the same memory a handful of threads would use.
25.1 The model
An async function returns a Future<T> instead of T directly. The future represents a value that does not exist yet. await suspends the current task until the future is ready, then unwraps the value:
import <future>; import <mem>; extern int printf(string fmt, ...);
Future<int>* produce() {
Future<int>* f = future_new<int>();
future_complete<int>(f, 41);
return f;
}
async int one() {
int n = await produce(); // suspends until produce()'s future is ready
return n + 1; // resumes with n = 41, returns 42
}
int main() {
Future<int>* r = one();
printf("%d", r.value); // 42
free_future<int>(r);
return 0;
}
The compiler transforms an async function into a state machine. Each await is a suspension point: the function can be paused there and resumed later without blocking any OS thread.
25.2 The event loop
Async tasks need something to run them. The event loop is a reactor that watches file descriptors for readiness and resumes the tasks waiting on them:
import <eventloop>; import <net_async>; import <net>; import <future>; import <mem>; extern int printf(string fmt, ...);
async void read_line(EventLoop* lp, int fd) {
*uint8 buf = alloc<uint8>(256);
int n = await net_read_async(lp, fd, buf, 255);
buf[n] = 0;
printf("%s", (string)buf);
free((*void)buf);
}
int main() {
EventLoop* lp = el_new(64);
int fd = net_tcp_connect("127.0.0.1", 7000);
Future<void>* f = read_line(lp, fd);
el_run(lp); // drives the event loop until all tasks complete
free_future<uint8>((*Future<uint8>*)f);
el_free(lp); net_close(fd);
return 0;
}
el_new(n) creates an event loop that watches up to n file descriptors. el_run(lp) blocks until all registered callbacks complete. Internally it uses kqueue on macOS and epoll on Linux.
25.3 Concurrent HTTP with http_serve_async
The simplest way to write a concurrent HTTP server (one event loop thread, many simultaneous connections):
import <http_async>; import <http>; import <eventloop>; import <net>; import <future>; import <mem>; extern int printf(string fmt, ...);
void hello(HttpRequest* req, HttpResponse* res) {
HttpResponse_header(res, "Content-Type", "text/plain");
HttpResponse_set_body(res, "Hello from Eskiu async\n");
}
int main() {
int fd = net_tcp_listen(8080);
EventLoop* lp = el_new(256);
Future<uint8>* f = http_serve_async(lp, fd, hello, 1000);
el_run(lp);
free_future<uint8>(f);
el_free(lp); net_close(fd);
return 0;
}
Each incoming connection is handled by a detached async task: a slow client never blocks others. The same handler function works with <http> (threaded) and <http_async> (event loop).
25.4 spawn, select2, and join2
Three combinators for composing concurrent tasks:
import <future>; import <mem>;
// spawn<T>: fire and forget, returns the future Future<int>* f = spawn<int>(some_async_fn());
// select2<A,B>: whichever finishes first // returns Either<A,B>: Left if a won, Right if b won import <either>; Future<Either<int,int>>* race = select2<int,int>(fa, fb);
// join2<A,B>: wait for both // returns Pair<A,B> Future<Pair<int,int>>* both = join2<int,int>(fa, fb);
select2 cancels the loser future automatically: its on_drop cascade cleans up any resources the losing task held.
25.5 Channels
A channel passes values between async tasks. chan_send enqueues a value; chan_recv returns a future that completes with the next value:
import <channel>; import <future>; import <mem>; extern int printf(string fmt, ...);
async int consume(Chan<int>* ch) {
int a = await chan_recv<int>(ch);
int b = await chan_recv<int>(ch);
return a + b;
}
int main() {
Chan<int>* ch = chan_new<int>(8);
chan_send<int>(ch, 10);
chan_send<int>(ch, 20);
Future<int>* f = consume(ch);
// drive on event loop or poll directly
printf("%d\n", f.value); // 30
free_future<int>(f);
chan_free<int>(ch);
return 0;
}
Sum Types and Match
A sum type is a type that can be one of several variants, each carrying its own data. They are the type system's answer to the question: how do you represent a value that might be one of several different things? Eskiu has sum types as algebraic enums (integer enums extended with payload variants) and a match statement that dispatches on them exhaustively.
26.1 Algebraic enums
An enum variant can carry a payload: one or more values of any type. The tag identifies which variant; the payload carries its data:
enum Expr {
Lit(int), // carries one int
Add(int, int), // carries two ints
Neg(int),
Zero, // no payload, like a classic enum variant
}
Classic integer enums still work unchanged. The two styles coexist freely in the same codebase.
26.2 match
match dispatches on the variant and binds the payload fields to names:
int eval(Expr e) {
match e {
Lit(v) -> return v;
Add(a, b) -> return a + b;
Neg(n) -> return 0 - n;
Zero -> return 0;
}
return -1;
}
printf("%d\n", eval(Lit(7))); // 7
printf("%d\n", eval(Add(3, 4))); // 7
printf("%d\n", eval(Neg(9))); // -9
printf("%d\n", eval(Zero)); // 0
Output: 7 7 -9 0
The compiler checks that every variant is covered. A missing arm is a compile error. A _ arm catches anything not listed above it:
int kind(Expr e) {
int k = 0;
match e {
Zero -> k = 1;
Lit(v) -> k = 2;
_ -> k = 9; // Add or Neg
}
return k;
}
26.3 Generic enums: Option and Either
Enums can have type parameters. The standard library provides two:
import <either>;
// Option<T>: a value that may or may not exist Option<int> o = Some<int>(42); Option<int> n = None<int>();
match o {
Some(v) -> printf("got %d\n", v); // got 42
None -> printf("nothing\n");
}
// Either<A,B>: one of two types
Either<int, string> ok = Left<int,string>(100);
Either<int, string> err = Right<int,string>("fail");
match ok {
Left(v) -> printf("value: %d\n", v); // value: 100
Right(s) -> printf("error: %s\n", s);
}
Option<T> is the alternative to null pointer checks. Either<T,E> is an alternative to Result<T,E> when you want to handle both cases symmetrically in a match rather than checking .ok.
26.4 Generic enum definitions
You can define your own generic enums. The compiler monomorphizes them, one concrete type per set of type arguments:
enum Tree<T> {
Leaf(T),
Node(T, T),
Empty,
}
Tree<int> t = Node<int>(3, 7);
match t {
Leaf(v) -> printf("leaf %d\n", v);
Node(a, b) -> printf("node %d %d\n", a, b); // node 3 7
Empty -> printf("empty\n");
}
Complete specification of syntax, types, operators, stdlib, and CLI
Lexical Elements
Comments
// single-line comment /* block comment, may span lines, does not nest */
Identifiers
Start with a letter or underscore, followed by letters, digits, or underscores. Case-sensitive.
my_var _internal Count x1
Keywords
let const volatile int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 float double bool char string void
struct packed union enum type interface fn extern intrinsic import
if else for while in switch case default return break continue match
alloc_with free_closure sizeof asm
thread_create thread_join async await spawn escaping
try catch finally throw
true false null
Types
Primitive Types
Type LLVM IR Width Notes
int i32 32 bits Signed; alias for int32
int8 i8 8 bits Signed
int16 i16 16 bits Signed
int32 i32 32 bits Signed
int64 i64 64 bits Signed
uint i32 32 bits Unsigned; alias for uint32
uint8 i8 8 bits Unsigned
uint16 i16 16 bits Unsigned
uint32 i32 32 bits Unsigned
uint64 i64 64 bits Unsigned
float float 32 bits IEEE 754 single-precision
double double 64 bits IEEE 754 double-precision
bool i1 1 bit true or false
char i8 8 bits Unsigned; widens to int via zero-extend
string i8* pointer Immutable C-string literal
void void - No value; valid only as return type
Compound Types
Type Syntax Notes
Pointer *T Both *T and T* accepted; canonical form is *T
Fixed array T[N] N must be a compile-time integer constant
Function ptr fn(T)->R Two-word fat pointer {fn_ptr, env_ptr}
Template Name Monomorphic; one copy per unique argument set
inst.
Interface Name Fat pointer {data_ptr, vtable_ptr} at call site
Type Casting
(int)x // explicit cast: required for all type conversions (uint8)n // narrowing: must be explicit (double)i // widening: must still be explicit (*uint8)ptr // pointer reinterpretation
No implicit conversions. Signedness is tracked; signed and unsigned variants of the same width share the same LLVM integer type.
Operators
Precedence Operators Associativit Notes
y
1 (lowest) = += -= *= /= %= Right Assignment
2 || Left Logical OR (short-circuit)
3 && Left Logical AND (short-circuit)
4 | (bitwise) Left
5 ^ Left Bitwise XOR
6 & (bitwise) Left
7 == != Left
8 < > <= >= Left
9 << >> Left >> is arithmetic on signed
10 +- Left
11 */% Left Integer / truncates toward 0
12 (unary) ! - + ~ & * (TYPE) Right
13 () [] . ? (postfix) Left ? on Result only
(highest)
Pointer Arithmetic
p + n on *T advances n × sizeof(T) bytes. Exception: *void and *char use byte stride (1 byte per step).
sizeof sizeof(T): compile-time int64 constant. No runtime code generated.
Control Flow
Statement Syntax Notes
if/else if (cond) { } else if (cond) { } else { } Condition: bool or integer (non-zero = true).
Braces required.
for for (init; cond; step) { } Each part optional. Loop variable scoped to
body.
for-in for (x in iterable) { } or for (i in A..B) { } x is a copy. Iterables: T[N] arrays, List-like
structs, half-open ranges A..B.
while while (cond) { } Condition checked before each iteration.
switch switch (x) { case N: ... break; default: Integer dispatch. No break = fallthrough.
... }
match match e { Variant(x) -> ...; _ -> ...; } Dispatch on algebraic enum variant. Exhaustive.
Binds payload fields.
break break; Exits innermost for/while/switch/match.
continue continue; Skips to next iteration. In for-in, correctly
advances the index.
return return expr; or return; return; for void functions.
try/catch try { } catch (T e) { } finally { } C++ EH. Link with -lc++ (macOS) or -lstdc++
(Linux).
throw throw expr; Any value type may be thrown.
Functions
Form Syntax Notes
Regular RetType name(T p, ...) { } Parameters by value. Declaration order irrelevant.
Void void name(T p) { } return; or fall off end.
Extern extern RetType name(T p, ...); ... only in extern. free is a keyword; do not
extern-declare it.
Lambda RetType(T p) { return expr; } Type is fn(T)->R. Closures capture by value at
creation.
Template T name(T a, T b) { } Monomorphic. Type args inferred from direct or
composite params.
Forward RetType name(T p); Optional: declaration order is already irrelevant.
decl
Function thread_create(myFunc) // Top-level named function decays to fn()->R.
decay myFunc: void myFunc() {} Compiler synthesises a thunk.
thread_crea thread_create(fn()->void f) -> Accepts closure. Fat ptr maps to pthread
te *void (start_routine, arg).
thread_join thread_join(*void h) -> void Blocks until thread h completes.
async async int f(...) { int x = await g(); Returns *Future. State machine transform. await
function return x; } only valid inside an async function.
escaping void store(escaping fn()->void cb) Marks closure param that outlives the call.
Non-escaping = stack env.
free_closur free_closure(f) Release escaping closure heap env.
e
variadic int f(int n, ...) { va_list ap; User-defined variadic. C promotions: float arrives as
va_start(ap); va_arg(ap); double.
va_end(ap); }
constraint T f(...) or T f(...) or struct S {...} Structs: define the interface methods. Primitives:
provide a free fn named like the method. int
cmp(int,int) makes int satisfy Ord. t.cmp(x) lowers to
cmp(t,x) inside the body.
? operator expr? on Result: returns the Err unchanged if ok==0, otherwise evaluates to the unwrapped value of type T. Only valid inside a function returning the same Result type.
Memory
Operation Syntax / Description
Stack alloc All local variables. Reclaimed on function return.
Heap alloc alloc(N): import <mem>; returns *T. libc malloc hosted, esk_alloc in freestanding.
Heap free free(ptr): calls free() or esk_free. Freeing null is safe.
Address-of &x: yields *T where x: T
Dereference *ptr: yields T where ptr: *T
Subscript ptr[i]: equivalent to *(ptr + i)
volatile volatile let p: *T = ...; all loads/stores through p emitted as volatile IR
Freestanding --freestanding: redirects alloc/free to user-provided esk_alloc/esk_free
Undefined Behaviour
Behaviour Example
Null dereference *null
Use-after-free free(p); *p = x;
Double-free free(p); free(p);
Buffer overrun arr[n] where arr has n elements (valid: arr[0]..arr[n-1])
Signed overflow INT_MAX + 1 wraps silently
Misaligned access reading a uint32 at a non-4-byte-aligned address (arch-dependent)
Preprocessor
Directive Effect
#define NAME value Object-like macro substitution
#define F(a,b) body Function-like macro. Invocation must fit one (post-continuation) line.
#define NAME Define NAME with empty value (for #ifdef checks)
#undef NAME Remove definition
#ifdef NAME Include block if NAME is defined
#ifndef NAME Include block if NAME is not defined
#else Alternate branch for #ifdef / #ifndef
#endif End conditional block
#pragma pack(1) Subsequent structs use 1-byte (packed) alignment
#pragma pack(push,1) Push current packing onto stack, switch to 1-byte
#pragma pack(pop) Restore previous packing from stack
#pragma pack() Restore default alignment
other #pragma Silently ignored
__APPLE__ Predefined by compiler on macOS
__linux__ Predefined by compiler on Linux
__FILE__ Current source file path (string literal)
__LINE__ Current source line number (integer)
__ESKIU_FREESTANDING__ Predefined when --freestanding is passed
#error message Abort compilation with message (honors #ifdef)
Macro bodies may span multiple lines using \ continuation. Macros expand recursively; a macro is
not re-expanded within its own expansion. The macro table is shared across all files in a multi-file build.
Standard Library
net: import <net>
Symbol Signature Notes
net_tcp_listen int net_tcp_listen(uint16 port) bind+listen; returns fd or -1
net_accept int net_accept(int listen_fd) accept; returns fd or -1
net_tcp_connect int net_tcp_connect(string host, uint16 port) connect; returns fd or -1
net_send int64 net_send(int fd, *void buf, int64 n) returns bytes sent
net_recv int64 net_recv(int fd, *void buf, int64 n) returns bytes received;
0=closed, -1=error
net_send_str int64 net_send_str(int fd, string s) send string literal
net_close void net_close(int fd) close connection or server fd
threading: import <threading>
Mutex: Mutex_init / Mutex_lock / Mutex_unlock / Mutex_destroy Cond: Cond_init / Cond_wait / Cond_signal / Cond_broadcast / Cond_destroy Sem: Sem_init(initial) / Sem_post / Sem_wait / Sem_destroy
http: import <http>
http_serve(port, workers, handler) // threaded worker pool handler type: fn(HttpRequest*, HttpResponse*)->void HttpRequest: method / path / version / headers / body (String fields) HttpResponse: HttpResponse_header / HttpResponse_set_body / HttpResponse_render
// Binary-safe full-body reader (use for uploads and binary data)
struct HttpReq { String method; String path; String headers;
*uint8 body; int64 body_len; }
HttpReq_init / HttpReq_free / HttpReq_header(r, name) -> string
int http_recv(int fd, HttpReq* r, int64 max_body) // loop recv full body
void http_reply(int fd, int status, string ctype, string extra, String* body)
void http_reply_error(int fd, int status, string extra, string msg)
json: import <json>
// Builder Json_init / Json_obj_begin / Json_obj_end / Json_arr_begin / Json_arr_end Json_key / Json_str / Json_int / Json_float / Json_bool / Json_null Json_cstr / Json_free // Parser *JsonValue json_parse(string text) *JsonValue JsonValue_get(*JsonValue obj, string key) double JsonValue_as_double / string JsonValue_as_cstr / int JsonValue_is_null void JsonValue_free(*JsonValue)
alloc: import <alloc> (explicit allocators over a buffer you own)
// All allocators use: *void T_alloc(T* self, int64 n) -> plugs into alloc_with Bump: Bump_init(buf, cap) / Bump_alloc / Bump_reset Arena: Arena_init / Arena_alloc / Arena_save / Arena_restore / Arena_reset Pool: Pool_init(buf, cap, blockSize) / Pool_alloc / Pool_free FirstFit: FirstFit_init(buf, cap) / FirstFit_alloc / FirstFit_free sysheap: heap_init / heap_alloc / heap_free / heap_destroy
map: import <map>
Map<V>: string-keyed hash map (open addressing, 0.75 load factor) Map_init<V>(&m, cap) // initialise *V Map_at<V>(&m, key, *int created) // get-or-insert; *created=1 if new int Map_get<V>(&m, key, *V out) -> 1/0 // lookup only Map_free<V>(&m) // free keys and value array
multipart: import
multipart_boundary(String* ctype, String* out) -> int // 1 = boundary found multipart_part(body, blen, boundary, name, &ptr, &plen) -> int
bytes: import <bytes>
Bytes: binary-safe *uint8 + length buffer (unlike String: NUL-safe, length-authoritative) Bytes_init(&b, cap) Bytes_free(&b) Bytes_push(&b, u8) Bytes_append_raw(&b, *uint8 src, n) Bytes_append(&b, &other) Bytes_slice(&b, start, n) -> Bytes (cap=0, non-owning) Bytes_to_base64(&b) -> Bytes Bytes_from_base64(s) -> Bytes HttpReq_body(&r) -> Bytes (non-owning view of HTTP body)
HashMap: generic key (also in <map>)
HashMap<K,V>: any-key map; you supply hash + eq fn pointers (like qsort) HashMap_init<K,V>(&m, cap, hash_fn, eq_fn) built-in: int_hash / int_eq *V HashMap_at<K,V>(&m, key, *int created) // get-or-insert int HashMap_get<K,V>(&m, key, *V out) -> 1/0 // lookup HashMap_free<K,V>(&m)
time / env / base64 / path: quick reference
import <time>; time_now_ms time_now_s time_monotonic_ms sleep_ms(ms) import <env>; env_get env_has env_get_or env_get_int import <base64>; base64_encode/decode(*uint8 src, int n, *uint8 out) import <path>; path_join path_basename path_dirname path_extension
CLI Reference
Flag Effect
eskiuc file.esk -o prog Compile and link into executable
eskiuc a.esk b.esk -o prog Compile multiple files (declarations merged)
eskiuc file.esk -o prog -lpthread Link with library (flags forwarded to linker)
eskiuc file.esk -o prog -L/path Add library search path
eskiuc file.esk -o prog --link-arg=X Pass extra argument to linker
eskiuc file.esk -c -o file.o Compile only: no linking
eskiuc file.esk -o file.o .o suffix implies -c
eskiuc file.esk -Wall Unused vars/params/fns + assignment-in-condition
eskiuc file.esk --target TRIPLE Cross-compile for target triple
eskiuc file.esk --freestanding No libc; alloc/free → esk_alloc/esk_free
eskiuc file.esk --test-lexer Dump token stream
eskiuc file.esk --test-parser Dump AST
eskiuc file.esk --test-typechecker Type-check only; print errors
eskiuc file.esk --test-codegen Dump LLVM IR
eskiuc file.esk --hover-at L:C Print inferred type at 1-based line:col
eskiuc file.esk --definition-at L:C Print definition location of symbol
eskiuc --version Print compiler and LLVM versions
Cross-compilation target triples
Triple Description
x86_64-pc-linux-gnu Linux x86-64
aarch64-unknown-linux-gnu Linux ARM64 (Raspberry Pi, Graviton)
aarch64-unknown-none-elf Bare-metal ARM64: no OS, no libc
x86_64-unknown-none Bare-metal x86-64: no OS, no libc
Linker detection order
eskiuc finds the system linker via: $CC → cc → clang → gcc. A C toolchain must be installed for linking. With --freestanding or a .o output, no linking occurs.
Undefined Behaviour
The following operations have undefined behaviour in Eskiu v0.2.0. The compiler makes no guarantees about what the generated code does when these occur. Bugs in these categories may crash, produce wrong results, corrupt memory, or appear to work correctly under some conditions and fail under others.
Category Description Detection
Null dereference Reading or writing through a null pointer. Crashes with SIGSEGV. ASan
reports immediately.
Use-after-free Accessing memory after free() has been ASan reports. Set pointer to
called on it. null after free to crash early.
Double-free Calling free() twice on the same pointer. ASan reports. Corrupts heap
metadata silently without ASan.
Buffer overrun Reading or writing past the end of an ASan reports. Corrupts
allocated buffer or fixed array. adjacent memory silently
without ASan.
Signed integer A signed arithmetic operation produces a Wraps silently. No detection
overflow value outside the representable range. without UBSan.
Uninitialized Reading a variable before assigning a value Returns indeterminate value.
read to it. Valgrind reports.
Misaligned access Reading a multi-byte type from a non-aligned Bus error on some ARM
(arch-dependent) address. Most common with packed struct targets. Silent on x86.
fields.
Data race Two threads access the same memory TSan (Thread Sanitizer)
concurrently with at least one write and no reports. Silent otherwise.
synchronisation.
Compile with AddressSanitizer to catch the first four categories: eskiuc file.esk -c -o file.o && clang -fsanitize=address file.o -o file_asan
Self-Hosting
There is a moment in a language's life when it grows up: the day its compiler is written in itself. Until then the language depends on another: Eskiu's reference compiler, eskiuc, is written in C++. A self-hosted compiler is one the language can build on its own, and reaching it proves something concrete: the language is expressive enough, and the compiler correct enough, to take on a program as demanding as a compiler. As of v0.3.0, Eskiu is there.
The whole pipeline is reimplemented in Eskiu, under selfhost/: a lexer, a preprocessor, a parser, a type checker, and a code generator, each a faithful translation of its C++ counterpart. They chain together exactly as the reference compiler does:
lexer → preprocessor → parser → type checker → code generator → LLVM IR
The code generator is the interesting one. It does not link against the LLVM libraries. Instead it writes LLVM IR out as plain text, which clang then assembles and links. That choice keeps the self-hosted compiler dependency-free, and it is how most bootstrapping compilers do it.
27.1 Trust, but verify
A compiler that is subtly wrong is worse than no compiler at all, so every pass is checked against the reference. The method changes with what can be observed. For the lexer, parser, and preprocessor there is an exact answer to compare against: their output is diffed byte-for-byte against eskiuc's debug dumps. For the type checker, the ground truth is a verdict: does it accept the same programs and reject the same mistakes, with the same error messages? For the code generator, the generated IR can't be compared character by character (LLVM renumbers values and folds constants), so the test is behavioural: compile the program both ways, run both binaries, and check that they print the same thing and exit the same way. Every one of these comparisons runs in continuous integration; a divergence fails the build.
27.2 The fixpoint
Per-pass parity is necessary but not sufficient. The real test of self-hosting is whether the compiler can reproduce itself. So the self-hosted compiler is built three times: the C++ eskiuc builds it once (call the result cc0); cc0 builds it again (cc1); cc1 builds it a third time (cc2). If the project is sound, cc1 and cc2 must emit identical IR for the compiler's own source: a compiler built by itself reproducing its own output. That equality is the bootstrap fixpoint, and Eskiu reaches it.
27.3 Earning the word "complete"
There is a trap here worth naming, because it caught the author more than once. The bootstrap fixpoint only exercises the slice of the language the compiler's own source happens to use. It says nothing about features the compiler doesn't use: floating point, say, or unions, or the ? operator. It is tempting to declare the code generator "complete" once the fixpoint holds. It is also wrong. The only way to know a feature works is to compile a program that uses it and run it. So feature-completeness is measured by pushing the entire reference test corpus through the behavioural oracle and demanding a clean sweep, with every program, compiled by the Eskiu-written code generator, behaving exactly as the C++ build does. v0.3.0 passes that sweep: floats, switch, sum types and match, closures, exceptions, atomics, generics with inference, async/await, unions, bitfields, interfaces, packed structs, variadics, and the ? operator all generate correct code.
The lesson generalises beyond compilers: a system that checks itself can only ever confirm the paths it walks. To claim coverage, you have to walk every path on purpose, and watch what happens.
Quick Reference
A.1 CLI flags
eskiuc file.esk -o prog # compile + link eskiuc a.esk b.esk -o prog # multi-file eskiuc file.esk -c -o file.o # compile only eskiuc file.esk -o prog -lpthread # link library eskiuc run file.esk [args...] # compile, run, delete eskiuc fmt file.esk # reformat in place eskiuc fmt --check file.esk # check formatting eskiuc file.esk -Wall # unused + assign-in-cond eskiuc file.esk -Wextra # signed/unsigned warnings eskiuc file.esk --asan -o prog # AddressSanitizer eskiuc file.esk --ubsan -o prog # trapping bounds checks eskiuc file.esk --target TRIPLE # cross-compile eskiuc file.esk --freestanding # esk_alloc/esk_free, no libc eskiuc file.esk --test-lexer # dump token stream eskiuc file.esk --test-parser # dump AST eskiuc file.esk --test-codegen # dump LLVM IR eskiuc file.esk --hover-at LINE:COL # inferred type at position eskiuc file.esk --definition-at L:C # definition location eskiuc --version # compiler and LLVM version
A.2 Keywords
// Types int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 float double bool char string void
// Declarations let const volatile struct packed union enum type interface fn extern intrinsic import
// Control flow if else for while in switch case default return break continue match
// Memory alloc_with free_closure sizeof asm
// Concurrency and async thread_create thread_join async await spawn escaping
// Error handling try catch finally throw
// Literals true false null
A.3 Standard library: quick reference
// Core
import <result>; Result<T,E> Ok<T,E>(v) Err<T,E>(e)
import <list>; List<T> List_init List_push List_get List_len List_free
import <string>; String String_from String_append String_split String_trim
import <math>; sqrt pow floor ceil fabs abs (link -lm)
import <io>; printf fprintf sprintf scanf puts
import <mem>; alloc<T>(n) free(p) memcpy memset memmove memcmp strlen
import <fs>; fs_open fs_close fs_read_all fs_write_all fs_puts fs_size
import <net>; net_tcp_listen net_accept net_tcp_connect
net_send net_recv net_close
// Allocators import <alloc>; Bump Arena Pool FirstFit (over alloc_with) import <sysheap>; Heap (mmap-backed, no libc malloc)
// Utilities import <time>; time_now_ms time_now_s time_monotonic_ms sleep_ms import <env>; env_get env_has env_get_or env_get_int import <base64>; base64_encode base64_decode base64_encoded_len import <path>; path_join path_basename path_dirname path_extension
// Concurrency import <threading>; Mutex Cond Sem (pairs with thread_create/thread_join) import <atomic>; atomic_load atomic_store atomic_swap atomic_cas
// Async/await runtime import <future>; Future<T> future_new future_complete future_poll free_future import <eventloop>; el_new el_add_read el_add_timer el_run el_stop el_free import <executor>; Executor (thread-safe ready-queue + wakeup) import <net_async>; net_read_async net_accept_async net_write_async import <timer>; timer_after(lp, ms) -> *Future<int> import <channel>; Chan<T> chan_new chan_send chan_recv chan_free
// HTTP import <http>; http_serve(port, workers, handler): threaded import <http_async>; http_serve_async(lp, fd, handler, n): event loop import <http2>; HTTP/2 framing + streams (RFC 7540) import <hpack>; HPACK header compression with Huffman (RFC 7541) import <http2_server>; http2_serve_async import <tls>; OpenSSL TLS + ALPN h2
// Sum types import <either>; Option<T> Either<A,B> opt_is_some opt_unwrap_or import <futureval>; select2v -> Either<A,B> join2v -> Pair<A,B>
// Data structures import <map>; Map<V> Map_init<V> Map_at<V> Map_get<V> Map_free<V>
// HTTP helpers import <multipart>; multipart_boundary multipart_part(body, len, ...)
A.4 Memory patterns
import <mem>;
// Stack: automatic, zero cost
int x = 5;
Point p = Point { x: 1.0, y: 2.0 };
// Heap: manual lifecycle *int arr = alloc<int>(100); // allocate arr[0] = 42; free((*void)arr); // release arr = null; // prevent use-after-free
// Pointers *int ptr = &x; // address-of *ptr = 100; // write through int n = *ptr; // read through
// Explicit allocator (Bump example) uint8[4096] backing; let b: Bump; Bump_init(&b, &backing[0], 4096); *int xs = alloc_with(&b, int, 16); // from the bump Bump_reset(&b); // free all at once
Glossary
ABI
Application Binary Interface. The binary-level contract between separately compiled code: how arguments are passed, where return values go, how structs are laid out. Eskiu uses the C ABI, which is why extern functions work transparently.
alloc<T>(N)
Heap-allocation stdlib function from <mem>, not a language keyword. Allocates N elements of type T on the heap and returns *T; calls malloc in hosted mode and esk_alloc in freestanding mode. Always paired with free.
AST
Abstract Syntax Tree. The tree-shaped data structure the parser builds from source code. Each node represents one language construct; the type checker and code generator both walk it.
async / await
Language keywords for non-blocking concurrency. An async function returns *Future; await E suspends the enclosing async function until the future is ready. The compiler lowers async functions to resumable state machines.
bump allocator
The simplest heap allocator. Keeps a pointer to the next free byte; allocation advances the pointer and free is a no-op. Used in the Eskiu kernel.
closure
A lambda that captures variables from its enclosing scope by value. Represented as a fat pointer {fn_ptr, env_ptr}, where env_ptr points to a heap-allocated copy of the captured variables.
constraint (bounded type parameter)
An interface requirement on a type parameter: <T: Ord> means the concrete type must provide every method declared by Ord. Multiple constraints are written <T: A + B>. Structs satisfy a constraint by defining the methods; primitives (int, float, …) satisfy it via a free function named like the method with the primitive as its first argument: int cmp(int, int) makes int satisfy Ord.
cross-compilation
Building a binary for a different machine than the one running the compiler. Controlled with --target TRIPLE.
data race
Two threads accessing the same memory concurrently, at least one writing, with no synchronisation: undefined behaviour. Closures avoid data races by capturing variables by value.
double-free
Calling free on the same pointer twice. Corrupts the heap. Prevented by setting pointers to null after free.
escaping closure
A closure whose environment outlives the function that created it, so it gets a heap-allocated env instead of a stack one. Mark the receiving parameter escaping; release it with free_closure(f).
event loop
A reactor that watches file descriptors for readiness (kqueue/epoll) and resumes parked futures when I/O is ready. Provided by <eventloop>: el_new / el_run / el_free.
extern
Declares a function from a C library. The compiler generates a C-ABI call and the linker resolves it; any C library is accessible this way.
fat pointer
A two-word value: {data_ptr, vtable_ptr} for interfaces, {fn_ptr, env_ptr} for closures.
freestanding
Compilation mode without libc. --freestanding redirects alloc and free to esk_alloc and esk_free, which the user provides.
Future
A handle to a value of type T that may not exist yet. Produced by async functions and leaf I/O primitives. Release with free_future(f). Import from <future>.
GEP
getelementptr. The LLVM IR instruction for pointer arithmetic, used to access struct fields and array elements without loading data.
heap
A large pool of memory managed with alloc and free. Allocations persist until freed and outlive the function that created them.
interface
A named set of method signatures. Any struct that provides all the required methods satisfies the interface (structural typing); the compiler generates a vtable automatically.
LLVM IR
The intermediate representation used internally: platform-independent, strongly typed, SSA form. Inspect it with --test-codegen.
memory leak
An allocation that is never freed. The program slowly consumes all available memory.
monomorphic template
A template that generates one concrete type or function per unique set of type arguments. No runtime overhead compared to hand-written type-specific code.
monomorphization
Generating a separate concrete copy of a template function or struct for each unique set of type arguments at compile time. Zero runtime overhead.
null
A pointer value that points to nothing. Dereferencing null crashes; check ptr != null before dereferencing.
pointer
A variable holding a memory address. *T is a pointer to T, &x gives the address of x, and *ptr reads or writes through the pointer.
Result
Template struct from stdlib: ok == 1 and value holds T on success; ok == 0 and error holds E on failure.
self
Implicit pointer to the struct instance inside a method body. Equivalent to this in C++ or Java.
stack
Memory automatically reclaimed as functions call and return. Local variables live here: fast, but limited in size.
struct
A composite type with named fields; can have methods. Created with a struct literal: Point { x: 1.0, y: 2.0 }.
structural typing
Interface-satisfaction rule: a struct satisfies an interface if it provides all the required methods, no explicit implements needed.
thread_create / thread_join
Language keywords. thread_create(fn) spawns a thread and returns a handle; thread_join(handle) blocks until the thread finishes. Link with -lpthread on Linux.
union
Like a struct, but all fields share offset 0; size equals the largest field. Used for type punning: treating the same bits as different types.
use-after-free
Reading or writing through a pointer after its memory was freed. Produces garbage or corrupts other allocations. Prevented by nulling after free.
volatile
Qualifier preventing the compiler from optimising away loads and stores to a pointer. Required for memory-mapped hardware registers.
vtable
Compiler-generated table of function pointers, one per interface method, for a specific struct. Part of the fat pointer used in interface dispatch.
The Book of Eskiu | Eduardo Dorantes | eskiu-lang.org | v0.7.0 | MIT License