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,decimaland 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:
@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:
@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:
@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=trapflag) 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--overflowflag. 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:
@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*andsaturating*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. Thecore.numericmodule 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:
@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:
- 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).
- On the Cortex-M targets,
floatarithmetic is software floating point by default; it works, but it is slow and pulls in a runtime routine for each operation (§3-3.2). - 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 = trueis specified to reject every floating-point type withE_FREESTANDING_FLOAT(Language Reference §3-3.21). The 1.25 toolchain accepts the key without enforcing it. You can already read it from source withif const (freestanding.no_float)to select an integer code path.
Summary
- The fixed-width types are
int8…int64anduint8…uint64;intanduintare pointer-sized, anduintholds addresses. - Literal suffixes (
u8…u64,i8…i64) fix a literal's width; a suffixed literal that does not fit isE_LITERAL_OVERFLOW. - Widths never promote: mixing them is
E_NUMERIC_TYPE_MISMATCH, and narrowing needsas, 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/saturatingAddmethods andoverflow = "trap"are planned, not implemented. - Bitwise NOT is
bnot;&,^,|bind more tightly than==. - Keep
bigint,decimaland floating point out of kernel code.
Next: functions and control flow.