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_stdwith no allocator: framing, dispatch and decoding over buffers the caller provides (runtime contract).packed@1needs 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_stdyet (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 arrayT[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_charandc_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]andc_byte[N]are C'schar 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_byteis 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, aschar[N]is. - In std, as
std::ffi::c_charandstd::ffi::c_byte, not global keywords, following Rust'score::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 achar[]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, astringforc_charand bytes forc_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
NulPaddedas the alternative toNulTerminated, 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, soc_charandc_byteare 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], noalloc,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
- IPC: transports, handles, peer identity, peer death. Use 1.
- Target constraints, with
no_stdgenerated code (comline-rust#83) as the first target. - The exact-layout format. Use 2.
- A C generator.
- 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_charandc_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.