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:

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.

SectionWhat it holds
familyThe family's name and who it inherits from
enums, flagsNamed lists of values that attributes can use
attr-groupsThe attributes themselves, organised in named groups
attr-setsNamed bundles of attributes: the shape of one message level
opsThe 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:

  1. The first line is a license comment. It is required, and every generated file inherits it.
  2. family names this family and says it inherits ctlv-attestation. It gets everything that family has, and adds to it. pre-init: true is a kernel-side flag; for now, ignore it.
  3. attr-groups declares one group, info, with three attributes. Each is a fixed 48-byte binary string.
  4. attr-sets fills device-info, an empty slot the parent family left open. Whatever the parent's device-get operation 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:

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.

WritePayloadUseful keys
type: uint64-bit unsigned integermin, max, unit, scale
type: sint64-bit signed integersame
type: boolOne byte, 0 or 1
type: stringTextmax-length (required), min-length
type: binaryRaw byteslength, or max-length and min-length
enum: nameOne value from a named list
flags: nameA bit mask from a named list
attrs: …A nested group of attributesoptional-attrs, recursive
blob: {…}A reference to caller memorysee 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:

  1. Documentation/ctlv/specs/ctlv-example.yaml — the specification. The file name must be the family name.
  2. 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.
  3. 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

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