IIO Scan Buffers: Decode Channel Width, Endianness and Alignment from Metadata
Build the IIO scan layout from enabled-channel metadata, then decode each field using its own storage width, byte order, shift, and signedness. A packed C structure or a list sorted by channel name can produce believable values while reading padding or the wrong channel.
Applicability: this article pins Linux v6.12. The original Python 3 example supports scalar integer storage widths of 8, 16, 32, and 64 bits, plus a conventional signed 64-bit timestamp. It deliberately rejects repeated elements, floating-point formats, and other storage widths. It is a synthetic decoder, not a complete IIO acquisition client.

1. Snapshot the layout metadata
Before capture, collect each enabled scan element’s name, index, and type from the selected buffer’s scan-element interface. Preserve the enabled mask alongside the data file. Resolve the actual device and buffer rather than assuming a particular device number is stable across boots.
The v6.12 IIO buffer documentation defines the type string’s byte order, signedness, valid bits, storage bits, optional repeat count, and shift. Lower scan indexes precede higher ones. Index gaps do not imply empty channels in the record, and alphabetical filename order is irrelevant.
Freeze configuration during capture. If the enabled mask or type changes, start a new layout generation and record boundary. Do not concatenate differently configured scans and rely on a decoder to infer where the change occurred. Read back configuration after setup and treat an unsupported requested mask as a setup failure.
2. Derive offsets and stride
For the scalar subset here, align each ordinary channel’s offset to its storage size before placing it. Append the enabled timestamp with its required alignment, then round the final record size to the largest element alignment. This follows v6.12 iio_compute_scan_bytes(); it is not a universal packed-structure rule.
Consider three explicitly synthetic elements: a at index 0 with le:s12/16>>4; b at index 3 with be:u24/32>>8; and timestamp at index 7 with le:s64/64>>0. Their offsets are 0, 4, and 8 bytes, and the scan stride is 16 bytes. Bytes 2 and 3 are padding, despite the sum of storage sizes being only fourteen bytes.
Disabling b moves no remaining payload into its old slot automatically: a still begins at zero and the timestamp still needs eight-byte alignment. That two-element scan remains sixteen bytes. With only a enabled, however, the stride is two bytes. Recompute layout for every accepted mask.
3. Decode in a defined order
Load the storage container as an unsigned integer using the element’s stated endianness. Shift right, mask to the valid-bit width, and then sign-extend from that width when the element is signed. Applying signed interpretation to the whole storage container before handling padding can produce the wrong result.
The synthetic record F0 FE AA BB 12 34 56 00 E8 03 00 00 00 00 00 00 decodes to a = −17, b = 0x123456, and timestamp = 1000. AA BB deliberately occupies padding. The timestamp is a constructed integer; no physical event time or clock epoch is implied.
Keep scale and offset application downstream of raw extraction. First prove that the raw integer matches the fixture, then apply the selected channel’s documented engineering-unit conversion. A plausible scaled value must never justify changing byte order experimentally until it looks right.
4. Use a bounded reference decoder
The following complete example accepts already collected channel dictionaries with name, index, type, and an optional timestamp flag. It requires exactly one complete scan. It has no sysfs writes, device reads, or assumptions about short-read behavior. Production capture code must accumulate complete records and retain any incomplete tail for the next read.
"""Synthetic scalar IIO scan decoder. Linux v6.12 model, not device I/O."""
import re
TYPE = re.compile(r'(be|le):([su])(\d+)/(\d+)(?:>>(\d+))?')
def layout(channels):
if not channels or len({c['index'] for c in channels}) != len(channels):
raise ValueError('empty scan or duplicate index')
if len({c['name'] for c in channels}) != len(channels):
raise ValueError('duplicate name')
fields, offset, largest = [], 0, 1
data = sorted((c for c in channels if not c.get('timestamp')), key=lambda c: c['index'])
stamps = [c for c in channels if c.get('timestamp')]
if len(stamps) > 1:
raise ValueError('multiple timestamps')
for c in data + stamps:
if not isinstance(c['index'], int) or c['index'] < 0:
raise ValueError('invalid index')
m = TYPE.fullmatch(c['type'].strip())
if not m:
raise ValueError('unsupported type; repeats and floats are excluded')
endian, sign, bits, storage, shift = m.groups()
bits, storage, shift = int(bits), int(storage), int(shift or 0)
if storage not in (8, 16, 32, 64) or not 0 < bits <= storage or bits + shift > storage:
raise ValueError('invalid scalar width or shift')
if c.get('timestamp') and (sign, bits, storage, shift) != ('s', 64, 64, 0):
raise ValueError('unsupported timestamp')
size = storage // 8
offset = (offset + size - 1) // size * size
fields.append(dict(c, offset=offset, size=size, endian=endian, sign=sign, bits=bits, shift=shift))
offset += size
largest = max(largest, size)
return fields, (offset + largest - 1) // largest * largest
def decode(record, channels):
fields, stride = layout(channels)
if len(record) != stride:
raise ValueError('expected one complete scan')
values = {}
for f in fields:
raw = int.from_bytes(record[f['offset']:f['offset'] + f['size']],
'big' if f['endian'] == 'be' else 'little')
value = (raw >> f['shift']) & ((1 << f['bits']) - 1)
if f['sign'] == 's' and value & (1 << (f['bits'] - 1)):
value -= 1 << f['bits']
values[f['name']] = value
return values
5. Test masks, metadata order, and invalid inputs
Run every nonempty subset of the three synthetic channels, and permute the metadata enumeration order for each subset. The layout and decoded values must remain identical for a given mask. Include the cases a-only, b-only, timestamp-only, a-plus-b, and both timestamp combinations; they expose assumptions hidden by the full layout.
Test negative and positive signed boundaries, zero, and nonzero unused bits. Change padding bytes while preserving payload bytes and require unchanged values. Reject duplicate indexes, invalid shifts, unsupported types, and a record shorter or longer than the declared stride. A repeated-element type requires explicit implementation; silently treating it as one scalar changes every subsequent offset.
The accompanying implementation was executed with Python 3.12.14 against seven channel masks, all metadata permutations for those masks, signed-boundary fixtures, ignored-bit cases, and the listed rejection cases. Those synthetic checks passed. No IIO device, kernel capture path, or sensor hardware was exercised.
6. Validate the live boundary separately
For target qualification, save the exact metadata snapshot, kernel and driver release, raw binary sample, and independently known input condition. Compare the first few records before enabling bulk scaling or storage. If decoding fails only after a mask change, inspect stride and generation handling before blaming sensor noise.
The release artifact is a layout table with offsets and stride, a raw fixture set, supported-format limits, and a capture-to-metadata association. This makes interpretation reproducible without claiming that a decoder unit test validates physical measurements or their timestamps.