The Loup Freestanding Book — contents
  1. Chapter 1: What Is Freestanding Mode?
  2. Chapter 2: Building a Bare-Metal Binary
  3. Chapter 3: Integer Types and Wrapping Arithmetic
  4. Chapter 4: Functions and Control Flow
  5. Chapter 5: Structs and Layout Control
  6. Chapter 6: Enums and Pattern Matching
  7. Chapter 7: Classes and Allocators
  8. Chapter 8: Interfaces and Generics
  9. Chapter 9: Error Handling with Result
  10. Chapter 10: Ownership Verbs
  11. Chapter 11: Borrow and Lifetimes
  12. Chapter 12: Unique Ownership
  13. Chapter 13: Memory Primitives
  14. Chapter 14: Inline Assembly
  15. Chapter 15: MMIO with Volatile
  16. Chapter 16: Interrupts and Naked Functions
  17. Chapter 17: Entry Points and Panic
  18. Chapter 18: Sections and the Linker
  19. Chapter 19: Multicore and Atomics
  20. Chapter 20: The Available Standard Library Subset
  21. Chapter 21: Example — x86_64 Kernel Stub
  22. Chapter 22: Testing and Deploying Firmware
  23. Chapter 23: Memory Safety and Kernel Development Patterns
  24. Chapter 24: synchronized Classes and Lock Ordering

Chapter 3: Integer Types and Wrapping Arithmetic

Kernel code is mostly integer code: register values, addresses, page-table bits, ring-buffer indexes, tick counters. This chapter covers the integer rules you meet in @mode(freestanding):

  • the fixed-width types and the pointer-sized uint;
  • why mixing widths is a compile error and how to convert;
  • the rule that fixed-width arithmetic wraps in freestanding;
  • how to detect overflow yourself today, and what is planned;
  • bit manipulation for addresses and flag words;
  • what to do about bigint, decimal and floating point.

The integer types

Signed Unsigned Width
int8 uint8 8 bits
int16 uint16 16 bits
int32 uint32 32 bits
int64 uint64 64 bits
int uint pointer width of the target (64 bits on x86_64, aarch64 and riscv64; 32 bits on armv7, riscv32 and Cortex-M)

uint is the type for addresses. Volatile[T].at(addr) takes one, addressOf(global) returns one, an @allocator returns uint?, and a buffer overlay is initialized from one. It is an integer, not a pointer type: you can add, mask and compare it, and nothing dereferences it unless you hand it to one of those APIs.

A plain integer literal has type int. A suffix gives it a fixed width: i8, i16, i32, i64, u8, u16, u32, u64. Underscores group digits, and 0x, 0b and 0o select the base:

@mode(freestanding)

# 16550 UART register offsets and bits.
const UART_THR: uint16 = 0u16          # transmit holding register
const UART_LSR: uint16 = 5u16          # line status register
const LSR_THR_EMPTY: uint8 = 0x20u8    # bit 5: ready for the next byte

# Local APIC base on x86_64 and a 2 MiB large-page mask.
const LAPIC_BASE: uint64 = 0xFEE0_0000u64
const LARGE_PAGE_MASK: uint64 = 0x1F_FFFFu64

func lsrReady(lsr: uint8): bool {
    return (lsr & LSR_THR_EMPTY) != 0u8
}

Listing 3-1: Fixed-width constants written with literal suffixes

There is no bare u suffix: 4096u is a syntax error. Write 4096u64, or give the binding a type and let the literal take it (let size: uint = 4096).

A suffixed literal that does not fit its type is rejected:

This code does not compile
@mode(freestanding)

func lineControl(): uint8 {
    let dlab: uint8 = 0x80u8
    let eightN1: uint8 = 0x03u8
    let bogus: uint8 = 0x103u8
    return dlab | eightN1 | bogus
}
error[E_LITERAL_OVERFLOW]: E_LITERAL_OVERFLOW: integer literal `0x103u8` does not fit in `uint8` (0..=255) (§1-3.6)
 --> src/main.lpfree:6:24
  |
6 |     let bogus: uint8 = 0x103u8
  |                        ^^^^^^^
  |

The check applies to suffixed literals. An unsuffixed literal assigned to a narrow binding (let b: uint8 = 300) is not caught by loup check today, which is one more reason to write the suffix on register constants.

Widths never mix

Loup does not promote between integer widths. Adding a uint32 to a uint64 is an error, not a silent widening:

This code does not compile
@mode(freestanding)

# Bytes written so far, and the size of the chunk just sent.
func advance(offset: uint64, chunk: uint32): uint64 {
    return offset + chunk
}
error[E_OPERATOR_MISSING]: E_NUMERIC_TYPE_MISMATCH: mixed 'uint64' and 'uint32' operands for operator '+' — integer widths do not promote; convert one operand explicitly (`as uint32`)
 --> src/main.lpfree:5:12
  |
5 |     return offset + chunk
  |            ^^^^^^^^^^^^^^
  |

The message offers one conversion; the one you want here is the widening one, because the result is a uint64:

func advance(offset: uint64, chunk: uint32): uint64 {
    return offset + (chunk as uint64)
}

Listing 3-2: Converting the narrower operand explicitly

Narrowing is never implicit either. Returning a uint16 where a uint8 is expected fails:

This code does not compile
@mode(freestanding)

func lowByte(value: uint16): uint8 {
    return value
}
error[E0001]: return type 'uint16' is not assignable to expected return type 'uint8'
 --> src/main.lpfree:4:5
  |
4 |     return value
  |     ^^^^^^^^^^^^
  |

Say what you mean with as. On the native targets (host.native and freestanding) a numeric as that cannot represent the value keeps the low bits — 300 as uint8 is 44, -1 as uint8 is 255 (Language Reference §1-4, ruling CAST-2). That is exactly the truncation a byte-splitting helper wants:

func lowByte(value: uint16): uint8 {
    return (value & 0xFFu16) as uint8
}

func highByte(value: uint16): uint8 {
    return (value >> 8) as uint8
}

func joinBytes(hi: uint8, lo: uint8): uint16 {
    return ((hi as uint16) << 8) | (lo as uint16)
}

Listing 3-3: Splitting and joining bytes with explicit conversions

Note the parentheses around each cast. In Loup as binds more loosely than the arithmetic, shift and comparison operators (Language Reference §1-3.7), so hi as uint16 << 8 does not mean what a C programmer expects. Parenthesize every cast that sits inside a larger expression.

Fixed-width arithmetic wraps

In @mode(host.native) and @mode(freestanding), plain +, - and * on fixed-width integers wrap at the type boundary: two's complement for signed types, modular for unsigned ones (Language Reference §1-4.1). There is no exception to throw into and no runtime to catch one. int and int64 wrap too in freestanding.

func wrapDemo(): uint8 {
    let a: uint8 = 250u8
    let b: uint8 = 10u8
    let sum = a + b          # 4   (260 mod 256)
    let product = a * b      # 196 (2500 mod 256)

    let up: int8 = 100i8
    let down = up + 50i8     # -106: signed overflow wraps; it is not undefined behaviour
    return sum + product
}

Listing 3-4: Wrapping is the defined behaviour of fixed-width arithmetic

Two things differ from C. Signed overflow is defined (it wraps), so the optimiser cannot assume it never happens. And the rule is the same for every width, so a uint8 counter and a uint64 counter behave alike.

Wrapping is often what kernel code wants. A free-running hardware counter wraps; a ring-buffer index computed modulo a power of two wraps; a hash or checksum is defined in terms of wrapping multiplication. Mark such functions with @overflowWrapping so the reader — and a future build configured to trap on overflow — knows the wrap is intended:

# FNV-1a over the first `n` bytes of a block. The multiply is meant to wrap.
@overflowWrapping
func fnv1a(data: buffer[uint8, 16], n: int): uint32 {
    var hash: uint32 = 0x811C9DC5u32
    var i = 0
    while (i < n) {
        hash = hash ^ (data[i] as uint32)
        hash = hash * 0x01000193u32
        i = i + 1
    }
    return hash
}

Listing 3-5: Documenting intended wrapping with @overflowWrapping

The Language Reference says @overflowWrapping keeps a function wrapping even when the package is built to trap on overflow. Today nothing traps (see the next note), so the annotation is documentation — which is still worth having.

⚠ Planned. The manifest key [freestanding] overflow = "trap" (and the --overflow=trap flag) is specified to make plain fixed-width +, -, * and / trap through your panic policy (Language Reference §3-3.19, §3-3.21). The 1.25 toolchain accepts the key but does not act on it, and there is no --overflow flag. Do not rely on a trap to catch overflow.

Detecting overflow yourself

When overflow would be a bug — a length field from a device, an address computed from user input, a counter that must not roll over — check for it. The pattern that works today is to widen, compute, and compare:

# Add two byte counts; None when the sum does not fit in a uint8.
func checkedAddU8(a: uint8, b: uint8): Option[uint8] {
    let wide = (a as uint16) + (b as uint16)
    if (wide > 255u16) {
        return None
    }
    return Some(wide as uint8)
}

# Add milliseconds to an uptime counter, sticking at the maximum instead of
# rolling over to 0 after about 49 days.
func saturatingAddU32(a: uint32, b: uint32): uint32 {
    let wide = (a as uint64) + (b as uint64)
    if (wide > 0xFFFF_FFFFu64) {
        return 0xFFFF_FFFFu32
    }
    return wide as uint32
}

func frameLength(header: uint8, payload: uint8): uint8 {
    match checkedAddU8(header, payload) {
        Some(total) => { return total }
        None => { return 0u8 }      # caller treats 0 as "malformed frame"
    }
}

Listing 3-6: Checked and saturating addition written with a wider type

For uint64 there is no wider type to widen into. Compare against the headroom instead: a + b overflows exactly when b > 0xFFFF_FFFF_FFFF_FFFFu64 - a.

The Language Reference also specifies a family of static methods for this — uint8.checkedAdd, uint8.wrappingMul, uint32.saturatingAdd and so on (§3-3.19). A reader who has seen them in the reference will naturally reach for them:

This code does not compile
@mode(freestanding)

func addLength(total: uint8, extra: uint8): Option[uint8] {
    return uint8.checkedAdd(total, extra)
}
error[E0001]: type 'uint8' has no method 'checkedAdd'
 --> src/main.lpfree:4:12
  |
4 |     return uint8.checkedAdd(total, extra)
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |

⚠ Planned. The checked*, wrapping* and saturating* method families on the fixed-width integer types (Language Reference §3-3.19) are not implemented in Loup 1.25. Write a helper like Listing 3-6 until they land. The core.numeric module cannot help here: it is a hosted module, and a freestanding file cannot import it.

Likewise, uint8.max and int64.min are not available as members today ("type 'uint8' does not support member access"); write the bound as a literal (255u8, 0xFFFF_FFFFu32).

Bits and addresses

Address arithmetic is masking and shifting. Loup spells bitwise NOT bnot, not ~ (the tilde is the lifetime-label sigil, chapter 11). A C habit produces this:

This code does not compile
@mode(freestanding)

const PAGE_SIZE: uint = 4096

func alignDown(addr: uint): uint {
    return addr & ~(PAGE_SIZE - 1)
}
error[E0001]: `~` is not an operator — bitwise NOT is spelled `bnot` (§1-3.7)
 --> src/main.lpfree:6:19
  |
6 |     return addr & ~(PAGE_SIZE - 1)
  |                   ^
  |

The corrected helpers:

const PAGE_SIZE: uint = 4096

func alignDown(addr: uint): uint {
    return addr & bnot (PAGE_SIZE - 1)
}

func alignUp(addr: uint): uint {
    return (addr + (PAGE_SIZE - 1)) & bnot (PAGE_SIZE - 1)
}

func isAligned(addr: uint): bool {
    return (addr & (PAGE_SIZE - 1)) == 0
}

Listing 3-7: Page alignment with bnot

alignUp wraps if addr is within one page of the top of the address space. Kernel addresses rarely are, but an address that came from user space can be anything; check it against your range limits before aligning it (chapter 23 shows the pattern).

Flag words are the other everyday use. Here are x86_64 page-table entry bits as constants and three helpers:

const PTE_PRESENT: uint64 = 1u64 << 0
const PTE_WRITABLE: uint64 = 1u64 << 1
const PTE_USER: uint64 = 1u64 << 2
const PTE_NO_EXECUTE: uint64 = 1u64 << 63
const PTE_ADDR_MASK: uint64 = 0x000F_FFFF_FFFF_F000u64

func makePte(frame: uint64, writable: bool): uint64 {
    var pte = (frame & PTE_ADDR_MASK) | PTE_PRESENT | PTE_NO_EXECUTE
    if (writable) {
        pte = pte | PTE_WRITABLE
    }
    return pte
}

func isPresent(pte: uint64): bool {
    return (pte & PTE_PRESENT) != 0u64
}

func frameOf(pte: uint64): uint64 {
    return pte & PTE_ADDR_MASK
}

Listing 3-8: Page-table entry flags as uint64 constants

One precedence difference from C works in your favour: &, ^ and | bind more tightly than == and != in Loup, so pte & PTE_PRESENT != 0u64 groups as (pte & PTE_PRESENT) != 0u64. The parentheses in the listing are there for C readers.

No bigint, no decimal

bigint needs a multi-precision arithmetic library and decimal a decimal128 library; a freestanding image links neither. The Language Reference makes both E_FREESTANDING_RUNTIME errors (§3-3.3). The 1.25 checker does not report a bigint declaration yet, so treat this as a rule you keep rather than one the compiler enforces: use fixed-width integers with explicit carry handling, or do the arbitrary-precision work in a hosted program.

Floating point

Floating-point types exist in freestanding, but kernels usually avoid them:

  1. On x86_64 the freestanding lanes compile with SSE and x87 disabled, so floating-point code does not belong in an x86_64 kernel at all (Language Reference §3-3.13).
  2. On the Cortex-M targets, float arithmetic is software floating point by default; it works, but it is slow and pulls in a runtime routine for each operation (§3-3.2).
  3. FPU state is large and must be saved on every context switch if any task uses it.

Most kernel math — frequencies, timeouts, scaling factors — is better written in fixed point on integers.

⚠ Planned. [freestanding] no_float = true is specified to reject every floating-point type with E_FREESTANDING_FLOAT (Language Reference §3-3.21). The 1.25 toolchain accepts the key without enforcing it. You can already read it from source with if const (freestanding.no_float) to select an integer code path.

Summary

  • The fixed-width types are int8…int64 and uint8…uint64; int and uint are pointer-sized, and uint holds addresses.
  • Literal suffixes (u8 … u64, i8 … i64) fix a literal's width; a suffixed literal that does not fit is E_LITERAL_OVERFLOW.
  • Widths never promote: mixing them is E_NUMERIC_TYPE_MISMATCH, and narrowing needs as, which keeps the low bits on native targets. Parenthesize casts.
  • Fixed-width arithmetic wraps in freestanding, signed included. Mark intended wrapping with @overflowWrapping.
  • Check for overflow by widening and comparing. The checkedAdd/wrappingAdd/saturatingAdd methods and overflow = "trap" are planned, not implemented.
  • Bitwise NOT is bnot; &, ^, | bind more tightly than ==.
  • Keep bigint, decimal and floating point out of kernel code.

Next: functions and control flow.