Skip to content

Comline for systems software

Status: draft · Scope decided 2026-11-02 (below) · Builds on IPC, Transports and bounds on types

The question: can Comline describe the interfaces of drivers and other systems software, and what would it need to?

"Writing drivers with Comline" covers four uses, with very different costs:

Use Examples What Comline needs Fit today
1. User-space drivers and their clients Fuchsia drivers, Android hardware layers, macOS DriverKit, Windows UMDF, Linux VFIO and libusb services IPC, with handles close
2. Kernel ↔ user-space interfaces ioctl structures, netlink messages, io_uring-style shared rings an exact memory layout, fixed sizes, no allocation partial
3. Hardware protocols register maps, USB descriptors, HID reports, I2C and SPI commands, CAN frames, Bluetooth GATT bit fields, byte order per field, fixed offsets far
4. Generated code inside a kernel Rust for Linux, Windows kernel drivers in C target constraints and a C generator far

Decided 2026-11-02: 1 first, through IPC; 2 planned, with an exact-layout format and target constraints; 3 and 4 later.

Where things stand:

  • The Rust runtime's lowest tier is no_std with no allocator: framing, dispatch and decoding over buffers the caller provides (runtime contract). packed@1 needs no allocator.
  • Decoders are hardened: a hostile length is refused before allocating, buffers are zeroed after each call, and generated code forbids unsafe.
  • Generated code isn't no_std yet (comline-rust#83).
  • The C ABI exposes the shared runtime core, but no generator writes C types.
  • With every value bounded (issue 6), every message has a known worst-case size: a target without an allocator can hold each message in a buffer sized at build time.

An exact-layout format

For shared memory, ioctl structures and reading values in place, both sides need the same bytes at the same offsets. No current format gives that:

packed@1 An exact-layout format
Layout fields one after another, no padding fixed offsets, with alignment and padding as the target's C ABI lays them out
Lengths varint prefixes none: everything fixed-size
Reading front to back any field, in place, at its offset
Byte order little-endian the target's own, or stated

What it would define:

  • Only fixed-size types: scalars, T[N], structs of them. A bounded array T[max N] takes room for N items plus a count. Unbounded types can't appear, which issue 6 already ensures.
  • Strings: a char[max N] keeps its meaning (characters) and takes a length plus 4 × N bytes of UTF-8 room: correct, if wasteful, for interfaces designed in Comline. C's own text has types of its own, c_char and c_byte.
  • optional, enums and unions: a presence flag, and a tag of a stated width.
  • The same layout in every language: generated Rust uses #[repr(C)], generated C a plain struct, and each states its size and offsets as compile-time checks, so a mismatch fails the build, not the kernel.
  • Any layout change is breaking: offsets move.

How it's named and versioned (a format such as c-layout@1, parameterized by target ABI) is open.

C text: c_char and c_byte

Proposed 2026-11-02. C structures measure text in bytes, end it with a NUL, and don't always promise an encoding: Linux file names are arbitrary bytes, some older Windows APIs use a legacy code page, and hardware strings are often Latin-1. Comline's char[max N] counts characters and is always valid Unicode. Rather than bend one onto the other, C text gets element types of its own, the way char is Unicode text's:

Type Holds Checked Rust
c_char[N] UTF-8 text the NUL rule, and UTF-8, always as_str() returns &str directly: checked when decoded, so it can't fail
c_byte[N] raw bytes, no encoding promised the NUL rule only as_bytes(), or &CStr
use std::ffi::{c_char, c_byte}

struct InterfaceRequest {
    /// C's `char ifr_name[16]`: up to 15 bytes of UTF-8, then a NUL.
    name: c_char[16]
    flags: u16
}

struct OpenRequest {
    /// A Linux path: up to 4095 bytes of any kind, then a NUL.
    path: c_byte[4096]
}
  • c_char[N] and c_byte[N] are C's char name[N], one to one: N bytes of storage holding up to N − 1 bytes of text, then a NUL. A C header translates directly.
  • UTF-8 is checked by default. Validation is on by default and given up explicitly (issue 8): choosing c_byte is that explicit choice, visible in the schema and in generated code. One type with a way to switch UTF-8 off was considered and not proposed: it hides the choice, and its generated API would have to stay fallible everywhere, since the type couldn't promise UTF-8.
  • Why element types, not CString[16]: a suffix after a name means an array of it (issue 2), so a sized type has to be an array of an element, as char[N] is.
  • In std, as std::ffi::c_char and std::ffi::c_byte, not global keywords, following Rust's core::ffi. They're the first std types whose meaning core knows, so generators treat them specially, as they already build in std's validators.
  • Checked: the NUL rule (below), and for c_char, UTF-8; converting a char[] refuses U+0000, which C would read as the end of the text.
  • Generated code: C, char name[16] for both, byte-identical to existing headers; Rust as above; TypeScript, a string for c_char and bytes for c_byte.

In json@1:

Type Sent as Decoder checks
c_char[N] a JSON string of the text, without the NUL or padding: "eth0" no \u0000; the text's UTF-8 length fits the NUL rule, in bytes, since the limit is C's storage
c_byte[N] the text's bytes, as an array of numbers, as json@1 sends any u8 array: [101,116,104,48] no 0 inside; the length fits the NUL rule

c_char is always valid UTF-8, so a JSON string loses nothing. c_byte as numbers keeps byte arrays consistent within json@1; base64 would be smaller, but should then apply to every byte array, a json@2 decision.

The NUL rule is a validator, on by default (suggested 2026-11-02). Whatever the rule, the text is read the same way: the bytes before the first NUL, or all N if there's none. Rules differ only in what they check, so they're validators, not types:

Rule Checks Typical use
NulTerminated(), the default a NUL within the N bytes: text of at most N − 1 bytes most C interfaces: ifr_name, utsname
NulPadded() text may fill all N bytes; any bytes after it are NUL fixed record formats: tar headers (name[100]), utmp (ut_user[32])
struct TarHeader {
    /// May fill all 100 bytes, with no NUL.
    @validators = [NulPadded()]
    name: c_byte[100]
}
  • Stating the other rule replaces the default. std declares NulPadded as the alternative to NulTerminated, so listing it replaces the default rather than adding to it. That isn't giving up a guarantee; it's a different rule, still checked.
  • Where the line falls between types and validators: a type when what's generated differs (UTF-8 makes Rust's accessor a &str, so c_char and c_byte are two types); a validator when only the check differs (the NUL rule generates the same accessor).
  • Not C strings at all: fields that may hold a NUL inside the text, such as Linux abstract socket addresses, which start with a NUL and are measured by a separate length. Those are u8[N] with a length field.

Target constraints

A target states what its generated code may and may not do, and the build checks the schema against it:

Constraint Values What the build does
Allocation none, fallible only, or global none: every message gets a fixed buffer sized from its worst case. Fallible: allocations that can fail return an error, never panic (as Rust for Linux requires)
Floating point allowed or not not allowed (most kernel contexts): f32 and f64 are refused for that target
Panics allowed or not not allowed: generated code is total, failing with errors only
Stack a budget in bytes decoding recursion (@max_depth) times its frame size is checked against it
unsafe forbidden or allowed forbidden by default, as today
  • Declared by the target, with defaults per kind of target (a kernel, an embedded device), and narrowed by the consumer's configuration.
  • Proven, not trusted: CI builds the generated code under the constraints (#![no_std], no alloc, panic = "abort"), and checks it can't panic.
  • Hover shows which targets a schema can't be generated for, and why.

A C generator

Kernel interfaces (2) and kernel code (4) are mostly C. The C ABI already carries the runtime; a generator for C types and their (de)coders would carry the schema. It's needed for 4, and useful for 2.

Hardware protocols, later

Bit fields, byte order per field and fixed offsets are a different kind of format from what Comline has. When 3 is taken up, it likely starts from packet dispatch's legacy framings.

Prior art

System What to learn
Linux UAPI headers the C structures user space and the kernel share
Linux netlink specifications (YAML, tools/net/ynl) a kernel interface described in a spec, with code generated from it
Fuchsia FIDL drivers drivers whose interfaces are an IDL over IPC
Android HIDL and AIDL hardware layers driver interfaces as versioned IDL
macOS DriverKit (IIG) user-space drivers with interfaces generated from a description
Rust for Linux fallible allocation, no panics, no floating point

Phasing

  1. IPC: transports, handles, peer identity, peer death. Use 1.
  2. Target constraints, with no_std generated code (comline-rust#83) as the first target.
  3. The exact-layout format. Use 2.
  4. A C generator.
  5. Later: hardware protocols (3) and kernel code (4).

Open

  • The exact-layout format: its name, and how a target ABI is named.
  • C text: c_char and c_byte, and the NUL rule as a validator on by default, as proposed.
  • Target constraints: the list, their defaults per kind of target, and where they're declared.
  • Proving no panics: which checks CI runs.
  • The C generator: its place in the plan, and whether it renders from the codec plan like the others.