Coding Best Practices

Interface Control Documents That Can’t Drift From the Code

jrhaws October 26, 2026 18 min read

Every embedded project with more than one team has the same three files. There’s the interface control document, written in Word and last updated at the design review. There’s the firmware’s C header, which is what actually runs. And there’s the app team’s parser, written from whichever version of the ICD they had at the time. Sooner or later the three disagree, and you find out from a field bug that turns out to be a byte-order or scaling mismatch.

The fix isn’t more careful editing. It’s having only one thing to edit. This post builds a small generator that turns one interface file into the C codec, a Python decoder, and the ICD page, then adds three checks that make drift fail the build.

TL;DR

  • Describe each message once, in a file the tools can read: fields, types, units, scales, ranges.
  • Generate everything else: the firmware codec, the host decoder, and the human-readable ICD.
  • In CI, regenerate and compare. A hand edit or a forgotten regeneration fails the build.
  • Lock each published layout. Changing it without a new version number fails the build too.
  • Test across the boundary: bytes from the C encoder go through the host decoder.
Three hand-kept copies of an interface drift apart; one interface file that generates the header, the decoder, and the ICD page cannotLeft, before: a Word ICD, the firmware's C header, and the app team's parser are each edited by hand, and dashed red lines mark where they disagree. Right, after: one file, icd/telemetry.yaml, feeds gen.py, which writes the C header and codec, the Python decoder, and the ICD page. CI runs gen.py –check to catch stale or hand-edited files and a round-trip test where the C encoder's bytes go through the Python decoder.Three copies drift. One source can't.Every copy of an interface someone edits by hand is a place for it to go wrong.BEFOREICD.docxrev C, last monthtelemetry.hfirmware, todayparser.pyapp team, rev B?differsdiffersAFTERicd/telemetry.yamlthe only file you editicd/gen.pygeneratortelemetry.h / .cC codechost/telemetry.pydecoderdocs/icd-telemetry.mdICD pageCI: gen.py –check (stale or hand-edited files fail)CI: C encoder bytes -> Python decoder (round trip)layout.lock: a published layout can't change silently
Before, three copies maintained by hand. After, one source, generated outputs, and CI checks that keep them honest.

One file describes the message

Here’s a telemetry notification for a sensor node, described the way a person would write it in an ICD table, just in YAML:

message: telemetry
id: 0x21
version: 1
byte_order: little
fields:
  - {name: timestamp_ms, type: u32, unit: ms,  scale: 1,    desc: Time since boot}
  - {name: temperature,  type: i16, unit: degC, scale: 0.01, min: -40, max: 85, desc: Board temperature}
  - {name: pressure,     type: u16, unit: kPa, scale: 0.1,  min: 0, max: 1000, desc: Line pressure}
  - {name: battery,      type: u16, unit: mV,  scale: 1,    min: 0, max: 4500, desc: Battery voltage}
  - name: flags
    type: u8
    desc: Status bits
    bits: {0: charging, 1: low_battery, 2: sensor_fault}Code language: YAML (yaml)

Everything a reader of the ICD needs is there: the order on the wire, the type and size of each field, the unit, the scale from raw value to physical value, the valid range, and what each flag bit means. The generator adds a message ID and a version byte to the front of every message.

Byte layout of telemetry version 1: 13 bytes, little-endian, drawn from icd/telemetry.yamlA bar of 13 bytes. Offset 0 msg_id (u8, 0x21); 1 version (u8); 2 to 5 timestamp_ms (u32, ms); 6 to 7 temperature (i16, 0.01 degC); 8 to 9 pressure (u16, 0.1 kPa); 10 to 11 battery (u16, mV); 12 flags (u8, bits for charging, low battery, sensor fault). Below, a dashed box at offsets 13 to 14 shows humidity, added in version 2, which gets a new version number instead of changing version 1.telemetry v1: 13 bytes, little-endianDrawn from icd/telemetry.yaml, like everything else in this post.msg_idu80vers…u81timestamp_msu3221 mstemperaturei1660.01 degCpressureu1680.1 kPabatteryu16101 mVflagsu81213humid…v2 onlyflags: bit 0 charging, bit 1 low_battery, bit 2 sensor_faultAdding humidity changes the layout, so it's version 2. Version 1 stays exactly as published.
The same file, drawn as bytes. Adding a field later means a new version, not a changed version 1.

Generate everything else

icd/gen.py reads that file and writes four others. The C header comes out like this:

/* Generated from icd/telemetry.yaml (sha256 3a655ffe4dc5). Do not edit; run icd/gen.py. */
#ifndef TELEMETRY_H
#define TELEMETRY_H
#include <stddef.h>
#include <stdint.h>

#define TELEMETRY_ID      0x21
#define TELEMETRY_VERSION 1
#define TELEMETRY_SIZE    13  /* bytes on the wire */
#define TELEMETRY_FLAGS_CHARGING (1u << 0)
#define TELEMETRY_FLAGS_LOW_BATTERY (1u << 1)
#define TELEMETRY_FLAGS_SENSOR_FAULT (1u << 2)

struct telemetry {
    uint32_t timestamp_ms; /* ms, x1: Time since boot */
    int16_t temperature; /* degC, x0.01: Board temperature */
    uint16_t pressure; /* kPa, x0.1: Line pressure */
    uint16_t battery; /* mV, x1: Battery voltage */
    uint8_t flags; /* Status bits */
};Code language: Arduino (arduino)

The encoder writes each byte explicitly instead of copying a packed struct, so the wire format doesn’t depend on the compiler’s padding or the CPU’s byte order:

size_t telemetry_encode(const struct telemetry *m, uint8_t out[TELEMETRY_SIZE])
{
    out[0] = TELEMETRY_ID;
    out[1] = TELEMETRY_VERSION;
    put_u32(out + 2, m->timestamp_ms);
    put_u16(out + 6, (uint16_t)m->temperature);
    put_u16(out + 8, (uint16_t)m->pressure);
    put_u16(out + 10, (uint16_t)m->battery);
    out[12] = m->flags;
    return TELEMETRY_SIZE;
}Code language: Arduino (arduino)

The Python decoder for the test harness and the app team gets a struct format string ("<BBIhHHB") and the scales, and the ICD page gets the table people actually read:

| Offset | Field | Type | Unit | Scale | Range | Description |
|---|---|---|---|---|---|---|
| 0 | msg_id | u8 |  |  |  | Message ID |
| 1 | version | u8 |  |  |  | Layout version |
| 2 | timestamp_ms | u32 | ms | 1 |  | Time since boot |
| 6 | temperature | i16 | degC | 0.01 | -40 to 85 | Board temperature |
| 8 | pressure | u16 | kPa | 0.1 | 0 to 1000 | Line pressure |
| 10 | battery | u16 | mV | 1 | 0 to 4500 | Battery voltage |
| 12 | flags | u8 |  |  |  | Status bits |Code language: plaintext (plaintext)

Every generated file says where it came from and carries a hash of the source, so anyone who opens one knows not to edit it.

Check 1: generated files match the source

Generating files doesn’t stop someone from editing them. Say the app team needs signal strength, and someone adds it straight to the header to unblock a demo. gen.py --check regenerates everything in memory and compares it with what’s in the repository:

$ python3 icd/gen.py --check; echo "exit $?"
--- include/telemetry.h (in the repo)
+++ include/telemetry.h (from the ICD)
@@ -20 +19,0 @@
-    uint8_t rssi;  /* added for the app team */
1 generated file(s) out of date
exit 1Code language: Shell Session (shell)

The same check catches the opposite mistake: someone changes the YAML and forgets to regenerate. Either way, the build fails before the copies disagree in a release.

Check 2: a published layout doesn’t change

The quieter failure is changing an interface that’s already in the field. Devices running version 1 will keep sending version 1 bytes for years. So the generator keeps a lock file with a fingerprint of every layout it has produced, keyed by message and version:

def check_lock(spec):
    wire, _ = layout(spec)
    sig = hashlib.sha256(json.dumps([(f["name"], f["type"]) for f in wire]).encode()).hexdigest()[:16]
    key = f"{spec['message']} v{spec['version']}"
    lock = json.loads(LOCK.read_text()) if LOCK.exists() else {}
    if lock.get(key, sig) != sig:
        return lock, f"layout of {key} changed: bump 'version' (the old layout is published)"
    lock[key] = sig
    return lock, NoneCode language: Python (python)

Add a humidity field without touching the version, and both the check and the generator refuse:

$ python3 icd/gen.py; echo "exit $?"
layout of telemetry v1 changed: bump 'version' (the old layout is published)
exit 1

$ sed -i 's/^version: 1/version: 2/' icd/telemetry.yaml && python3 icd/gen.py
wrote 4 files for telemetry v2

$ cat icd/layout.lock
{
  "telemetry v1": "faea90a5118f2a19",
  "telemetry v2": "e76bfcf9ea3c16fc"
}Code language: Shell Session (shell)

The whole change lands as one reviewable diff, source and outputs together:

$ git diff --stat
 docs/icd-telemetry.md |  7 ++++---
 host/telemetry.py     | 12 ++++++------
 icd/layout.lock       |  3 ++-
 icd/telemetry.yaml    |  3 ++-
 include/telemetry.h   |  7 ++++---
 src/telemetry.c       |  4 +++-
 6 files changed, 21 insertions(+), 15 deletions(-)Code language: Shell Session (shell)

The decoder rejects a version it doesn’t know rather than misreading it. What the host does with older versions, whether it decodes both or asks the device to update, is a decision for the ICD, but it’s now an explicit one.

Check 3: test across the boundary

The last check proves the two sides agree on actual bytes. A small C program encodes a sample with the generated encoder and prints the bytes; a pytest test decodes them with the generated Python decoder:

$ ./build/roundtrip
210140e201001efb590d800e02

$ python3 -m pytest -q -p no:cacheprovider test/test_roundtrip.py 2>&1 | tail -1
3 passed in 0.01sCode language: Shell Session (shell)

The sample includes a negative temperature (1efb is -1250, or -12.50 °C), because sign handling is exactly where hand-written parsers go wrong. In CI, all three checks run on every push:

jobs:
  icd:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v7
      - name: Generated files match the ICD
        run: |
          python3 -m venv .venv
          .venv/bin/pip install -q pyyaml==6.0.3 pytest==9.1.1
          .venv/bin/python icd/gen.py --check
      - name: C encoder and Python decoder agree
        run: |
          mkdir -p build
          gcc -std=c11 -Wall -Wextra -Wconversion -Werror -Iinclude test/roundtrip.c src/telemetry.c -o build/roundtrip
          .venv/bin/python -m pytest -q test/test_roundtrip.pyCode language: YAML (yaml)

Where this stops

This generator handles fixed-size integer fields, which covers a lot of embedded telemetry and command messages. Real interfaces also have variable-length data, optional fields, enums, and timing rules that say how often a message is sent and what happens when one is late. You can grow a generator like this one, adopt an interface description language that already handles those cases, or keep the interface in a system model. A SysML v2 model can carry the same field definitions and generate the same outputs. The source format matters less than the rule: one place to edit, everything else generated, and CI checking both.

The written parts of an ICD still matter, too: why the message exists, who sends it, and what the receiver must do with each flag. That text can live in the source file as descriptions, so it travels with the fields it explains.

Wrap-up

  • Keep one machine-readable definition of each message, with units, scales, and ranges.
  • Generate the firmware codec, the host decoder, and the ICD page from it.
  • Fail CI on hand edits and on forgotten regeneration.
  • Freeze published layouts; any change gets a new version.
  • Test real bytes across the boundary, including negative and boundary values.

Which copy of your interface would you trust if two of them disagreed today?


Keep going

Generating interfaces from one source is a big part of The Model Is Not the Code, the book I’m writing on SysML v2 for software engineers, where the source is a system model instead of a YAML file.

If you’d like this for your system, the SysML v2 Interface Model package models your logical architecture and interfaces in SysML v2, generates the ICD, JSON Schemas, and C header from the model, and adds a drift check to CI. See the Services page or get in touch.

Tags code generation documentation embedded systems interface control document systems engineering

Leave a Reply

Your email address will not be published. Required fields are marked *