Guide: writing a CTLV specification
This guide builds up the YAML language one idea at a time. It assumes you can read YAML and have a rough idea what an ioctl is. For exhaustive rules, keep the reference open alongside.
1. What a specification is
A CTLV device exposes a set of operations. Userspace sends a request and receives a reply; the device may also announce events. Every request and reply is a tree of typed attributes. A family is the named collection of operations and attributes one kind of device supports.
You write down the family in a single YAML file. From that file the tooling generates:
- a UAPI header with the numeric IDs userspace uses,
- a kernel header and source with metadata and typed accessors for driver code,
- the schema the running kernel uses to reject malformed messages before your driver code sees them.
The YAML is therefore not documentation about the interface. It is the interface, and it is checked into an ABI baseline so that it cannot drift by accident.
The pieces
A specification has up to six top-level sections. Only family is required.
| Section | What it holds |
|---|---|
family | The family's name and who it inherits from |
enums, flags | Named lists of values that attributes can use |
attr-groups | The attributes themselves, organised in named groups |
attr-sets | Named bundles of attributes: the shape of one message level |
ops | The operations, each pointing at the attr sets for its request and reply |
The separation between declaring an attribute (in a group) and using it (through a set, in an op) is the main idea of the language. Declare an attribute once, then reuse it in as many messages as you like.
2. A first look
The smallest real specification in the tree belongs to ctlv-attestation-tdx. It extends an attestation family with three Intel TDX registers:
# SPDX-License-Identifier: ((GPL-2.0 WITH Linux-syscall-note) OR BSD-3-Clause)
%YAML 1.2
---
family:
name: ctlv-attestation-tdx
inherits: ctlv-attestation
pre-init: true
attr-groups:
- name: info
attrs:
- name: mrconfigid
type: binary
min-length: 48
max-length: 48
- name: mrowner
type: binary
min-length: 48
max-length: 48
- name: mrownerconfig
type: binary
min-length: 48
max-length: 48
attr-sets:
- name: device-info
attrs:
- info.mrconfigid
- info.mrowner
- info.mrownerconfig
Read it top to bottom:
- The first line is a license comment. It is required, and every generated file inherits it.
familynames this family and says itinheritsctlv-attestation. It gets everything that family has, and adds to it.pre-init: trueis a kernel-side flag; for now, ignore it.attr-groupsdeclares one group,info, with three attributes. Each is a fixed 48-byte binary string.attr-setsfillsdevice-info, an empty slot the parent family left open. Whatever the parent'sdevice-getoperation replies, it now also includes these three attributes.
There are no ops here at all. This family adds data to an operation it inherited rather than defining a new one. That mechanism, a stub, is covered in section 9.
3. Attributes and groups
We will build a small invented family, ctlv-example, that manages network ports. Start with the header and the attributes:
# SPDX-License-Identifier: ((GPL-2.0 WITH Linux-syscall-note) OR BSD-3-Clause)
%YAML 1.2
---
family:
name: ctlv-example
inherits: ctlv
attr-groups:
- name: port
attrs:
- name: index
type: uint
max: 255
- name: label
type: string
max-length: 31
- name: mtu
type: uint
min: 68
max: 9000
Every family except the root one, ctlv, must say which family it inherits. A family directly below the root names ctlv. The name of a child must be its parent's name, a dash, and a suffix; that is why ctlv-example works under ctlv.
A group is a namespace. The attribute is referred to elsewhere as port.index, port.label and port.mtu. The group name also becomes part of the generated C names, such as CTLV_EXAMPLE_ATTR_PORT_INDEX.
Each attribute has a name and exactly one primary form: type, enum, flags, attrs or blob. Here all three use type. The remaining keys narrow what values are valid. The kernel enforces them: a request with port.index equal to 300 is refused before a driver sees it.
Order matters. An attribute's wire ID comes from its position in its group, and a group's from its position in the file. Reordering or deleting entries changes the ABI. Append, never rearrange.
4. Attr sets
Declaring an attribute does not put it in any message. An attr set does that. A set lists which attributes appear at one level of a message, split into required and optional:
attr-sets:
- name: port-select
attrs: port.index
- name: port-config
attrs: port.index
optional-attrs:
- port.label
- port.mtu
- name: port-entry
attrs: port.index
optional-attrs:
- port.label
- port.mtu
A list entry is a selector. It can be:
group.attr— one attribute,group— every attribute in the group,set— every member of another set.
When a list has one item, you may write it without the dash, as above. A required attribute must be present in every message of that shape, otherwise the kernel replies with an error naming the missing attribute. An optional one may be left out.
Sets can be defined once and reused by several operations, so port-entry can describe both a single reply and each item of a list.
5. Operations
Operations tie request and reply shapes to a name:
ops:
- name: port-set
type: action
request:
attrs: port-config
- name: port-get
type: action
fd-access: read
request:
attrs: port-select
reply:
attrs: port-entry
An action runs once and returns one reply. port-set has no reply, which means it returns a bare acknowledgement. The request and reply keys each take attrs and optional-attrs selectors, just like a set.
fd-access says what kind of file descriptor may run the operation. read allows an observation-only descriptor; the default, read-write, needs a full-control one. So port-get can be used by monitoring tools, and port-set cannot.
Operations are numbered by position in the ops list, starting at 1.
Optional and dynamic operations
Two more switches control whether an operation is available on a particular device:
optional: true- The operation does not exist on a device unless the driver turns it on during initialization. If it is not turned on, it is absent from introspection.
availability: dynamic- The driver may disable and re-enable the operation at run time. A disabled operation stays visible but returns
-EOPNOTSUPP.
6. Choosing types
An attribute's primary form decides what its payload is.
| Write | Payload | Useful keys |
|---|---|---|
type: uint | 64-bit unsigned integer | min, max, unit, scale |
type: sint | 64-bit signed integer | same |
type: bool | One byte, 0 or 1 | |
type: string | Text | max-length (required), min-length |
type: binary | Raw bytes | length, or max-length and min-length |
enum: name | One value from a named list | |
flags: name | A bit mask from a named list | |
attrs: … | A nested group of attributes | optional-attrs, recursive |
blob: {…} | A reference to caller memory | see below |
Integers are always 64 bits on the wire; use min and max to say how big they can really get. Strings must declare max-length, so every message has a known worst-case size.
Enums and flags
Declare the list once, then refer to it by name:
enums:
- name: link-state
values:
- down
- up
flags:
- name: link-caps
values:
- autoneg
- duplex
attr-groups:
- name: port
attrs:
# …index, label, mtu as before…
- name: state
enum: link-state
- name: caps
flags: link-caps
Enum values are numbered from 1 in the order written (0 is reserved), and flag bits from bit 0. As with everything numbered by position, only append to either list.
Units
An integer can carry a unit (seconds, hertz, bytes or celsius) and a decimal scale exponent, so that tools can interpret it. scale only makes sense with a unit:
- name: queue-len
type: uint
unit: bytes
Blobs
A blob does not carry data inside the message. It describes a buffer in the caller's address space, for payloads too large to inline:
- name: data
blob:
direction: input
allowed-backings: [ va ]
max-length: 1048576
alignment: 8
direction says whether the driver reads the buffer (input), writes it (output) or both (inout). Blobs appear only in requests.
7. Repetition and nesting
Repeated attributes
By default an attribute occurs once. max-occurrences permits more:
- name: object
attrs: evidence-object-request
max-occurrences: 16
The value unlimited is also accepted, but only for attributes that appear in a query reply (next section), because unlimited things have no worst-case size.
Nested attributes
An attribute written with attrs holds a whole sub-message. Combined with max-occurrences, it makes a list of records:
- name: list
attrs:
- name: port
attrs: port-entry
max-occurrences: unlimited
Here list.port is an attribute whose payload is a port-entry, and a reply may contain many of them. Nests may go at most 31 levels deep, and the generator rejects cycles.
A nest that must contain itself, such as a tree, uses recursive: true with exactly one attr set. This is only for query replies, and is used by the schema-introspection operation of the root family.
8. Queries and events
There are three operation types. You have seen action. The other two:
- name: ports-dump
type: query
fd-access: read
reply:
attrs: list.port
- name: port-changed
type: event
reply:
attrs: port-changed-info
A query may produce a reply too large for the caller's buffer, in which case it is returned in fragments. This is why only queries may have unlimited repetition. A query must declare a reply. You can hint the buffer size to start with using suggested-reply-length; the generator rejects a value below what the largest unsplittable element of the reply needs.
An event is sent by the device without being asked. It has a reply shape and nothing else: no request, no fd-access, no optional, no availability. Userspace subscribes to the events it cares about and reads them from the file descriptor.
The complete example. The four operations above, with the enums, flags, groups and sets from the earlier sections, form the whole ctlv-example spec used in this guide. It passes the --validate check of the generator once registered.
9. Inheritance and extension
A family inherits from exactly one parent. The child gets all of the parent's operations, attributes and sets with their IDs unchanged. A device of the child family answers every operation of every ancestor, which is how the root family's discovery operations work on every device.
A child may add anything new. It can only change what the parent already declared in one way: by filling a stub.
Stubs
A parent reserves an extension point by declaring an attr set with stub: true and no members, and using it like any other attribute source:
# in ctlv-example
attr-groups:
- name: port
attrs:
# …
- name: details
attrs: port-details
attr-sets:
- name: port-details
stub: true
- name: port-entry
attrs: port.index
optional-attrs:
# …
- port.details
A child declares an attr set with the same name and gives it members:
# ctlv-example-vendor.yaml
family:
name: ctlv-example-vendor
inherits: ctlv-example
attr-groups:
- name: vendor
attrs:
- name: serial
type: uint
attr-sets:
- name: port-details
optional-attrs: vendor.serial
Now every port-entry reply from a vendor device can carry a port.details nest containing vendor.serial. The parent never mentions the vendor; the vendor never edits the parent. On devices of the parent family the unfilled extension point contributes nothing to the message.
That is exactly what the TDX spec in section 2 does. A stub can be filled once, by one descendant in a chain, and a fill is also part of the ABI.
10. Registering and generating
A specification is not live until it is registered. Three files change together:
Documentation/ctlv/specs/ctlv-example.yaml— the specification. The file name must be the family name.Documentation/ctlv/families.yaml— append the family name. Its position in the list is its numeric ID, so this list is append-only. A parent must appear before its children.Documentation/ctlv/generated-files.yaml— say where the generated files go:
- name: ctlv-example
source: Documentation/ctlv/specs/ctlv-example.yaml
outputs:
uapi-header: include/uapi/linux/ctlv_example_gen.h
kernel-header: drivers/example/ctlv_example_gen.h
kernel-source: drivers/example/ctlv_example_gen.c
Then run:
make -C tools/ctlv regen # write the generated files
make -C tools/ctlv all # validate and compare against the ABI snapshots
Never edit a generated file by hand. Change the YAML and regenerate. The tool is Python and needs pyyaml and jsonschema (tools/ctlv/requirements.txt).
What comes out
For ctlv-example the UAPI header defines names such as:
CTLV_EXAMPLE_ATTR_PORT_INDEX /* from group "port", attr "index" */
CTLV_EXAMPLE_OP_PORT_SET /* from op "port-set" */
CTLV_EXAMPLE_LINK_STATE_UP /* from enum "link-state", value "up" */
CTLV_EXAMPLE_LINK_CAPS_AUTONEG /* from flags "link-caps" */
Dashes become underscores and everything is upper case. If two YAML names would collapse to the same C name, the generator reports the clash instead of silently dropping one.
The kernel header gives driver code typed getters and putters for each attribute and a callback to implement for each operation. Those are described in Documentation/driver-api/ctlv.rst.
11. Changing a family later
Once a family ships, userspace depends on it. The tooling keeps a checked-in ABI snapshot of each family under Documentation/ctlv/abi/ and refuses a spec that is incompatible with it. The snapshot is produced by:
make -C tools/ctlv abi-update
Run it deliberately: the diff of the snapshot is how reviewers see an interface change.
Safe changes
- Appending new attributes, groups, ops, enum values and flag bits.
- Adding a new attribute to an existing message as optional.
- Widening a bound: a larger
max, a smallermin, moremax-occurrences. - Making a required attribute optional.
Breaking changes
The checker names each one. Narrowing port.index from 255 to 200:
ctlvgen: ctlv-example against ctlv/abi/ctlv-example.yaml: the schema is not compatible with its snapshot
attr port.index: max was narrowed from 255 to 200
Making existing optional attributes required:
op port-set request: port.label became required
op port-set request: port.mtu became required
Adding a new attribute to a message as required:
op port-changed reply: new port.mtu must be optional
Removing or renumbering anything is also a break, which is why the order of declarations matters. In the registry, a family that is no longer wanted can be marked deprecated, but its slot is never reused:
families:
- ctlv
- name: ctlv-old-thing
deprecated: true
12. Where to go next
- The reference has every key, default, limit and error rule.
Documentation/driver-api/ctlv.rstshows how to implement a family's callbacks, hooks and events in a driver.Documentation/userspace-api/ctlv.rstdocuments the wire format and the discovery protocol.- The real families to read next are
ctlv.yaml(the root, showing recursion and events) andctlv-attestation.yaml(a full family with optional and dynamic operations).