The Book
of Eskiu

A complete introduction to systems programming

Eduardo Dorantes

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

Preface (Why this book exists)
Chapter 1: Hello, Eskiu (Your first program from scratch)
Chapter 2: Variables and Types (Naming values and describing their shape)
Chapter 3: Functions (Defining, calling, and composing behaviour)
Chapter 4: Control Flow (Decisions, loops, for-in, and early exits)
Chapter 5: Structs and Methods (Grouping data with the behaviour it needs)
Chapter 6: Pointers and Memory (Addresses, stack, heap, and the rules)
Chapter 7: Interfaces (Polymorphism without inheritance)
Chapter 8: Templates (Generic code at zero runtime cost)
Chapter 9: Closures and Lambdas (Functions as values, functions with memory)
Chapter 10: Error Handling (Result, the ? operator, and exceptions)
Chapter 11: Concurrency (Threads, closures, and shared state)
Chapter 12: Operators (Arithmetic, bitwise, sizeof, preprocessor)
Chapter 13: Multi-file Programs (import, multi-file CLI, and project structure)
Chapter 14: C Interop (Calling any C library from Eskiu)
Chapter 15: The Standard Library (result, list, string, math, io, mem, fs, net)
Chapter 16: Inline Assembly (Talking directly to hardware)
Chapter 17: Cross-compilation (Building for ARM64, x86-64, and bare-metal)
Chapter 18: Bare-metal Programming (A kernel in Eskiu: no libc, no OS)
Chapter 19: Ownership and Resources (Designing safe code with alloc and free)
Chapter 20: Debugging (Segfaults, leaks, and diagnostic strategies)
Chapter 21: Testing (Assertions, unit tests, and test organisation)
Chapter 22: Common Bugs (The mistakes everyone makes and how to avoid them)
Chapter 23: Networking (TCP sockets, echo servers, and HTTP in Eskiu)
Chapter 24: Real World: INE QR Decoder (A cryptographic pipeline in production Eskiu)
Chapter 25: Async and Await (Concurrent I/O without threads)
Chapter 26: Sum Types and Match (Algebraic enums, Option, Either)
Chapter 27: Self-Hosting (The compiler written in Eskiu, the bootstrap fixpoint, feature-completeness)
Appendix A: Quick Reference (CLI flags, keywords, stdlib at a glance)
Appendix B: Glossary (Terms used throughout this book)

Part II: Language Reference

Ref R1: Lexical Elements (Comments, identifiers, keywords, literals)
Ref R2: Types (Primitives, pointers, arrays, structs, templates)
Ref R3: Operators (Complete operator table with precedence)
Ref R4: Control Flow (if, for, for-in, while, switch, break, continue)
Ref R5: Functions (Regular, lambda, closure, template, thread primitives)
Ref R6: Memory (Stack, heap, alloc/free, volatile, freestanding)
Ref R7: Preprocessor (#define, #ifdef, #pragma pack, multi-line macros)
Ref R8: Standard Library (Complete API for result, list, string, math, io, mem, fs, net)
Ref R9: CLI Reference (All compiler flags, linking, cross-compilation)
Ref R10: Undefined Behaviour (What the compiler does not guarantee)
Preface

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.

Chapter 1

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.

Chapter 2

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

Chapter 3

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.

Chapter 4

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.

Chapter 5

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.

Chapter 6

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
Chapter 7

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

Chapter 8

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.
Chapter 9

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)
Chapter 10

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.

Chapter 11

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.
Chapter 12

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.
Chapter 13

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
Chapter 14

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.

Chapter 15

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
Chapter 16

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
Chapter 17

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 */ }
Chapter 18

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.

Chapter 19

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.

Chapter 20

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).

Chapter 21

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.
Chapter 22

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.

Chapter 23

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;
 }
Chapter 24

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.

Chapter 25

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;
 }
Chapter 26

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
Ref R1

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
Ref R2

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.

Ref R3

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.

Ref R4

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.
Ref R5

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.

Ref R6

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)
Ref R7

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.

Ref R8

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
Ref R9

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.

Ref R10

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

Chapter 27

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.

Appendix A

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
Appendix B

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