Using HSL in UDDF#

HSL Overview#

Hardware Sequence Language (HSL) is a simple language for specifying I2C and GPIO hardware accesses. HSL has no conditionals, no arithmetic, and no control flow—it encodes a flat list of hardware operations that the framework executes in order. The standard source language for HSL is PyHSL, a Python DSL that compiles to HSL bytecode.

Drivers use HSL in two ways:

  • Static HSL: Sequences are authored in PyHSL and compiled ahead of time. The resulting bytecode is embedded into C++ headers as compile-time constants and submitted directly to hardware.

  • Dynamic HSL: Sequences are built at runtime through the II2CBuilder and IGPIOBuilder interfaces. The framework converts the builder output into HSL bytecode behind the scenes.

Both models submit through the same IHardwareAccess::SubmitSequence() entry point. The following diagram shows how both paths converge:

UDDF PDK - HSL Submission

Static HSL#

UDDF drivers are encouraged to use static HSL sequences wherever possible. Instead of building I2C commands at runtime, you author HSL in PyHSL source files (.py). When the driver is compiled, the HSL source is compiled too. The resulting bytecode is transformed into a C++ header containing HSLStaticSequence objects that the driver passes directly to IHardwareAccess::SubmitSequence().

The workflow:

  1. Author sequences in PyHSL.

  2. The build system compiles the .py files into .hslc bytecode and then into C++ headers.

  3. Include the generated header in your driver and submit sequences by name.

A generated header looks like this:

// Generated by the HSL toolchain
inline constexpr uddf::cdi::HSLStaticSequence<134U> init_device {
    std::array<uint8_t, 134U>{ 0x0c, 0x00, 0x02, ... }, "init_device"
};

// Usage in driver code
context.hwAccess->SubmitSequence(hsl::init_device);

The following example shows a serializer driver that checks for hardware presence by reading device and revision ID registers into a memory block. The PyHSL file defines the device, the memory layout, and the sequence:

ser = I2CDevice(0x40, 16, 8, 'serializer')

id_layout = MemoryLayout('SerializerIdData')
id_layout.addItem('deviceId', 1)
id_layout.addItem('revisionId', 1)
id_block = MemoryBlock(id_layout, 'ser_id_block')

with Sequence('read_device_ids'):
    seq.annotate('Read serializer device and revision IDs')
    with ser:
        readToMemory(DEVICE_ID_REG, id_block.deviceId)
        readToMemory(REVISION_ID_REG, id_block.revisionId)

The driver includes the generated header and submits the sequence. Because this sequence reads data into a memory block, the driver uses GetErrorState() to verify the read succeeded before using the data (refer to Querying the Error State):

hsl::SerializerIdData idData{};
hwAccess->SubmitSequence(hsl::read_device_ids, idData);

if (!hwAccess->GetErrorState()) {
    return false;
}

UDDF_LOG_INFO(driverServices, "Serializer: device=0x%02X revision=0x%02X",
              idData.deviceId[0], idData.revisionId[0]);
UDDF PDK - Static HSL Workflow

Writing Generic HSL Files and Address Retargeting#

Many devices support multiple I2C addresses selectable through hardware: address pins, GPIO straps, or OTP configuration. A deserializer datasheet might list 0x29, 0x2B, 0x4B, and 0x6B as valid addresses depending on the board’s address pin wiring. A power load switch might sit at 0x28 on one board and 0x2A on another. The same driver needs to work with all of them.

PyHSL sequences are compiled once with a fixed canonical address, and the framework retargets them to the actual runtime address automatically. Without this, you would need separate compiled sequences for every address variant.

PyHSL side. Author the I2CDevice with the canonical address. Nothing special is required in the .py file:

deser = I2CDevice(0x29, 16, 8, 'deserializer')

with Sequence('init_deserializer'):
    with deser:
        write(0x0010, 0x03)
        write(0x0330, 0x00)

C++ side. In ConfigureDriver(), populate the DeviceTableEntry with both i2cAddress (the real runtime address on this board) and hslI2cAddress (the canonical address used when compiling PyHSL). When hslI2cAddress is present and differs from i2cAddress, the HAL rewrites the static HSL bytecode before submission:

bool ConfigureDriver(const DeserializerContext& context,
                     uddf::ddi::DeviceTable& deviceTable) override
{
    deviceTable.push_back(uddf::ddi::DeviceTableEntry{
        .i2cAddress    = context.basicConfig.i2cAddress,  // runtime: 0x2B
        .offsetWidth   = 2,
        .dataWidth     = 1,
        .flags         = 0,
        .hslI2cAddress = 0x29,  // canonical address used in PyHSL
    });
    return true;
}

When to skip. If the compile-time and runtime addresses are the same (the common case), omit hslI2cAddress. It defaults to i2cAddress and no rewriting occurs.

This mechanism works for all driver types, not just GMSL. Refer to GMSL for how address retargeting interacts with GMSL virtual address translation.

Dynamic HSL#

When register values or the set of operations depend on runtime conditions, you build sequences dynamically through the II2CBuilder interface.

The workflow:

  1. Request an IHSLDynamicSequence object from IHardwareAccess::GetDynamicSequence().

  2. Retrieve an II2CBuilder for a specific I2C device address and enqueue commands.

  3. Submit the sequence through IHardwareAccess::SubmitSequence(). The framework converts the builder output to HSL bytecode and sends it to hardware.

After submission, the sequence object is no longer valid. Call GetDynamicSequence() again to begin a new sequence.

The following example shows an EEPROM driver that dynamically reads a block of calibration data into a caller-provided memory buffer. The driver does not know the read offset until runtime because it depends on the module configuration:

auto& sequence = hwAccess->GetDynamicSequence(
    /* memoryBlockView */ { calibBuffer, calibSize });

auto* eeprom = sequence.i2cBuilder(eepromAddress);
eeprom->readToMemory(calibOffset, 0, calibSize);

hwAccess->SubmitSequence(sequence);
return true;

Memory I/O#

Static HSL sequences normally use hardcoded register values, but many real scenarios require runtime-calculated data. HSL memory I/O bridges this gap by letting a static sequence reference fields in a driver-provided memory block.

PyHSL side. Define a MemoryLayout with named fields and a MemoryBlock instance. Use writeFromMemory and readToMemory in your sequences:

sensor = I2CDevice(0x36, 16, 8, 'sensor')

exposure_layout = MemoryLayout('ExposureGainData')
exposure_layout.addItem('exposureHigh', 1)
exposure_layout.addItem('exposureLow', 1)
exposure_layout.addItem('analogGain', 1)
exposure_layout.addItem('digitalGain', 1)
exposure_block = MemoryBlock(exposure_layout, 'exposure_block')

with Sequence('set_exposure_gain'):
    seq.annotate('Program per-frame exposure and gain')
    with sensor:
        writeFromMemory(0x3500, exposure_block.exposureHigh)
        writeFromMemory(0x3501, exposure_block.exposureLow)
        writeFromMemory(0x3508, exposure_block.analogGain)
        writeFromMemory(0x350A, exposure_block.digitalGain)

C++ side. The HSL toolchain generates an ExposureGainData struct that mirrors the layout. Each generated struct embeds a memory_block_tag that the framework resolves automatically. The driver populates the struct and passes it directly to SubmitSequence():

hsl::ExposureGainData data = {
    .exposureHigh = {{ static_cast<uint8_t>((exposureLines >> 8) & 0xFF) }},
    .exposureLow  = {{ static_cast<uint8_t>(exposureLines & 0xFF) }},
    .analogGain   = {{ static_cast<uint8_t>(analogGainCode) }},
    .digitalGain  = {{ static_cast<uint8_t>(digitalGainCode) }},
};

hwAccess->SubmitSequence(hsl::set_exposure_gain, data);

This pattern separates what to program (PyHSL) from which values to use (C++ driver logic). It is particularly useful for per-frame sensor updates where the sequence structure is fixed but the register values change every frame.

Combining Dynamic and Static HSL#

Both submission models can be combined freely, even within a single entrypoint. A common pattern is to use static sequences for the bulk of initialization and finish with a short dynamic sequence for values that are only known at runtime.

The following example shows a sensor Init entrypoint that runs precompiled register tables and then sets a virtual channel ID dynamically:

// Run static initialization sequences
hwAccess->SubmitSequence(hsl::sensor_power_on);
hwAccess->SubmitSequence(hsl::sensor_mode_config);

// Set the virtual channel ID, which is only known at runtime
auto& vcSequence = hwAccess->GetDynamicSequence();
vcSequence.i2cBuilder(sensorAddress)->write(VC_ID_REG, virtualChannelId);
hwAccess->SubmitSequence(vcSequence);

The only constraint is that static HSL sequences are read-only. You cannot modify a static sequence after compilation; use a dynamic sequence or memory I/O for any runtime-variable parts.

GPIO Operations#

HSL supports GPIO pin control through the IGPIOBuilder interface, which is structurally similar to II2CBuilder but operates on GPIO pins instead of I2C registers. You retrieve a GPIO builder from the dynamic sequence by calling gpioBuilder(address) with the platform-specific GPIO address.

IGPIOBuilder provides three operations:

  • write(level) — Drive a pin to GPIOLevel::HIGH or GPIOLevel::LOW.

  • readVerify(expectedLevel) — Read a pin and verify its level.

  • poll(expectedLevel, interval, retries) — Repeatedly read a pin until it reaches the expected level.

GPIO pins must be declared in the GpioPinTable during ConfigureDriver(), just as I2C devices are declared in the DeviceTable.

The following example shows a power driver toggling a module power-enable pin:

bool PowerOn(IHardwareAccess* hwAccess) {
    auto& sequence = hwAccess->GetDynamicSequence();
    auto* gpio = sequence.gpioBuilder(powerEnablePin);
    gpio->write(GPIOLevel::HIGH);
    hwAccess->SubmitSequence(sequence);
    return true;
}

bool PowerOff(IHardwareAccess* hwAccess) {
    auto& sequence = hwAccess->GetDynamicSequence();
    auto* gpio = sequence.gpioBuilder(powerEnablePin);
    gpio->write(GPIOLevel::LOW);
    hwAccess->SubmitSequence(sequence);
    return true;
}

Debugging HSL Sequences#

When an HSL sequence fails, the framework logs a structured backtrace that pinpoints the exact operation that failed and shows what preceded it.

Reading HSL Backtraces#

The following is a real backtrace from a deserializer driver that failed to enable a GMSL link:

[Deser_3]  +===================[ HSL FAILURE ]=========================+
[Deser_3]
[Deser_3]    Sequence: "enable_link_B" (operation 4/7)
[Deser_3]
[Deser_3]    History:
[Deser_3]      ✓ Op 1: [Note] "Enable link B"
[Deser_3]      ✓ Op 2: WriteMasked 0x29 @ 0x0005 = 0x0000 (mask 0x0080)
[Deser_3]      ✓ Op 3: WriteMasked 0x29 @ 0x0006 = 0x0002 (mask 0x0002)
[Deser_3]      ✗ Op 4: Poll 0x29 @ 0x000A until 0x0008 (mask 0x0008, interval 10000us, retries 50)
[Deser_3]          ╰─> HSL_I2C_ERROR_POLL_TIMEOUT: expected 0x0008, read 0x0000
[Deser_3]
[Deser_3]    3 operations not executed
[Deser_3]
[Deser_3]  +===========================================================+

Each part of the backtrace conveys specific information:

  • Sequence name and operation index. "enable_link_B" (operation 4/7) identifies the sequence and indicates that the fourth of seven operations caused the failure.

  • History. Each completed operation is marked with a checkmark (). The failed operation is marked with a cross (). You can trace exactly what the hardware executed before the failure.

  • Error type and values. HSL_I2C_ERROR_POLL_TIMEOUT: expected 0x0008, read 0x0000 tells you that the poll timed out because register 0x000A on device 0x29 never produced the expected bit pattern.

  • Remaining operations. 3 operations not executed indicates how many operations in the sequence were skipped after the failure. The framework stops execution on the first error.

  • Instance prefix. [Deser_3] identifies which driver instance produced the log. This is essential when multiple instances of the same driver are running.

Where to Find the Logs#

HSL backtraces are emitted through the system logger:

  • QNX — Use slog2info to view the logs. You can filter by buffer name to isolate UDDF output.

  • Linux — Check syslog or use journalctl. The output appears in the standard kernel or system log.

The driver instance prefix (for example, [Deser_3]) helps you filter output when multiple camera modules are active simultaneously.

Error Handling#

SubmitSequence() returns void. Your driver does not need to check for or propagate HSL errors. The camera HAL catches and retains errors from every sequence submission automatically. If a sequence fails, the HAL logs a detailed backtrace (refer to Reading HSL Backtraces above), skips all subsequent SubmitSequence() calls for the remainder of the entrypoint invocation, and marks the entrypoint as failed – regardless of what the driver returns.

This means the typical driver code is simply:

hwAccess->SubmitSequence(hsl::init_sequence);
hwAccess->SubmitSequence(hsl::config_sequence);
return true;

If the first submission fails, the second is silently skipped and the framework handles the rest.

Querying the Error State#

In some cases, the driver itself needs to know whether a submission succeeded. Common reasons include:

  • The submission read data into a memory block (through readToMemory) and the driver must verify the read succeeded before using that data.

  • The driver needs to take a different code path depending on whether an operation succeeded.

  • The driver wants to log additional context that only it has.

For these cases, IHardwareAccess::GetErrorState() returns the HSLResult from the first failed submission in the current entrypoint invocation (or a success result if nothing has failed). HSLResult converts to bool (true on success).

The following example reads device identification registers into a memory block and checks the result before using the data:

hsl::SerializerIdData idData{};
hwAccess->SubmitSequence(hsl::read_device_ids, idData);

if (!hwAccess->GetErrorState()) {
    UDDF_LOG_ERROR(driverServices, "Failed to read serializer IDs");
    return false;
}

UDDF_LOG_INFO(driverServices, "Serializer: device=0x%02X revision=0x%02X",
              idData.deviceId[0], idData.revisionId[0]);

HSLResult is a variant (defined in uddf/cdi/HSLResult.hpp) that holds one of four types:

  • HSLResultSuccess — No error occurred.

  • HSLResultI2CError — An I2C operation failed.

  • HSLResultGPIOError — A GPIO operation failed.

  • HSLResultFrameworkError — The framework itself encountered an error (for example, an invalid blob or a memory bounds violation).

When you need more detail about the failure:

HSLResult result = hwAccess->GetErrorState();
if (!result) {
    if (auto* i2cErr = result.asI2CError()) {
        UDDF_LOG_ERROR(driverServices,
            "I2C error %s at address 0x%02X offset 0x%04X",
            i2cErr->typeName().data(), i2cErr->address, i2cErr->offset);
    }
    return false;
}

The I2C error types that can appear in a backtrace are:

  • NACK — The device did not acknowledge.

  • BUS_TIMEOUT — The I2C bus timed out.

  • ARBLOST — Arbitration was lost (multi-master conflict).

  • READ_VERIFY — A readVerify operation returned an unexpected value.

  • POLL_TIMEOUT — A poll operation exhausted all retries without matching.

  • READBACK — Post-write readback verification failed.

  • INVALID_PARAM — An invalid parameter was passed (for example, an unregistered I2C address).

  • INTERNAL — An internal driver error occurred.

Annotations#

The [Note] entry visible in the backtrace history (Op 1: [Note] "Enable link B") comes from a PyHSL seq.annotate('...') call. Annotations produce no hardware operations; they exist solely to label sections of a sequence so that backtraces and logs are easier to read.

Add annotations at the start of logical sections within your sequences:

with Sequence('init_sensor'):
    seq.annotate('Reset sensor')
    with sensor:
        write(RESET_REG, 0x01)
        poll(STATUS_REG, 0x00, 0xFF, 1000, 10)

    seq.annotate('Configure output format')
    with sensor:
        write(FORMAT_REG, 0x0A)
        write(LANE_REG, 0x04)

When a failure occurs, the most recent annotation tells you which logical section the failure belongs to, even if the failing operation is several steps removed from the annotation.

HSL Header Generation (drvhsl)#

The HSL toolkit’s drvhsl tool converts compiled HSL bytecode files into C++ header files. While driver developers typically do not invoke this tool directly, it serves an essential role in making HSL bytecode accessible to your UDDF driver. The tool takes binary HSL container files (.hslc) and transforms them into ready-to-include C++ headers containing HSLStaticSequence objects with embedded bytecode as compile-time constants. This allows your driver to directly reference precompiled HSL sequences without needing runtime file I/O or dynamic memory allocation.

drvhsl handles the mechanical work of converting binary data to properly formatted C++ arrays, managing namespace organization, constant naming, and header guards. The included Makefile helper (hsl_compile_rules.mk) automatically invokes this tool, making static HSL sequences as straightforward to use as including any other header file.

usage: drvhsl.py [-h] [-i source-file] [-d output-directory] [-b basename]
                 [-p prefix] [-n namespace]
                 [-l {debug,info,warning,error,critical}]

Convert a .hslc file into C++ code to retrieve HSL bytecode blobs

options:
  -h, --help            show this help message and exit
  -i source-file, --input source-file
                        source .hslc file to process
  -d output-directory, --directory output-directory
                        output directory for generated files
  -b basename, --basename basename
                        basename for generated files (defaults to input filename)
  -p prefix, --prefix prefix
                        prefix for generated constants
  -n namespace, --namespace namespace
                        namespace for the generated code (e.g., "mydriver::hsl")
  -l {debug,info,warning,error,critical}, --loglevel {debug,info,warning,error,critical}
                        Logging level

HSL Makefile Integration#

The HSL Makefile helper (hsl_compile_rules.mk) provides a streamlined way to compile PyHSL scripts into C++ header files and integrate them into your UDDF drivers. This file handles the compilation pipeline from HSL source files to generated C++ headers that you can directly include in your driver code.

Prerequisites#

  • Python 3 and the HSL SDK package

  • GNU Make

Configuring the HSL Module#

Set four required variables in your Makefile before including the helper. In the examples below, PATH_TO_DRIVER is the root of your driver project and PATH_TO_HSL_SDK is the root of the HSL SDK:

HSL_SOURCES    := cam123_hsl.py
HSL_SOURCE_DIR := $(PATH_TO_DRIVER)/drivers/gmsl
HSL_SCRIPT_DIR := $(PATH_TO_HSL_SDK)/pyhsl/src
HSL_OUTPUT_DIR := $(PATH_TO_DRIVER)/hsl_gen

include $(PATH_TO_HSL_SDK)/integrations/make/hsl_compile_rules.mk

HSL_SOURCES lists the HSL Python source files (relative to HSL_SOURCE_DIR), HSL_SCRIPT_DIR points to the directory that contains hslcompile.py and drvhsl.py, and HSL_OUTPUT_DIR is where the generated headers are written.

Usage#

After the include directive, the helper exposes two output variables that you can reference in your own rules:

  • HSL_GENERATED_HEADERS — the list of generated .hpp files.

  • HSL_GENERATED_HSLC_FILES — the list of intermediate .hslc files.

Declare a dependency on $(HSL_GENERATED_HEADERS) so that your driver objects rebuild whenever an HSL source changes:

$(DRIVER_OBJS): $(HSL_GENERATED_HEADERS)

You can also invoke make hsl_generate to run the HSL compilation step in isolation.

Example#

The following snippet shows a minimal Makefile for a camera driver that compiles two HSL sources and links the driver into a shared library:

HSL_SOURCES    := SensorSequences.py SerializerSequences.py
HSL_SOURCE_DIR := $(PATH_TO_DRIVER)/drivers/gmsl
HSL_SCRIPT_DIR := $(PATH_TO_HSL_SDK)/pyhsl/src
HSL_OUTPUT_DIR := $(PATH_TO_DRIVER)/hsl_gen
HSL_NAMESPACE  := my_driver::hsl

include $(PATH_TO_HSL_SDK)/integrations/make/hsl_compile_rules.mk

CPPFLAGS += -I$(HSL_OUTPUT_DIR)

DRIVER_SRCS := drivers/gmsl/SensorDriver.cpp
DRIVER_OBJS := $(DRIVER_SRCS:.cpp=.o)

$(DRIVER_OBJS): $(HSL_GENERATED_HEADERS)

%.o: %.cpp
     $(CXX) $(CPPFLAGS) $(CXXFLAGS) -fPIC -c -o $@ $<

libmy_driver.so: $(DRIVER_OBJS)
     $(CXX) -shared $(LDFLAGS) -o $@ $^ $(LDLIBS)

Configuration Variables#

Variable

Type

Required

Description

HSL_SOURCES

List

Yes

HSL Python source files to compile, relative to HSL_SOURCE_DIR.

HSL_SOURCE_DIR

Path

Yes

Absolute path to the directory that contains the HSL source files.

HSL_SCRIPT_DIR

Path

Yes

Absolute path to the directory that contains hslcompile.py and drvhsl.py.

HSL_OUTPUT_DIR

Path

Yes

Absolute path to the directory for generated output files.

HSL_CONFIG_PARAMS

List

No

Configuration parameters passed as -D defines to hslcompile.py.

HSL_NAMESPACE

String

No

C++ namespace for generated headers.

HSL_CONSTANT_PREFIX

String

No

Prefix for generated constants.

HSL_DEPENDENCIES

List

No

Additional files (relative to HSL_SOURCE_DIR) that trigger recompilation when changed.

HSL_OUTPUT_NAME.<src>

String

No

Override the generated header basename for a specific source file.

Generated Files#

For each HSL source file, the following files are generated:

  • <basename>.hslc — Compiled HSL bytecode (intermediate)

  • <basename>.hpp — C++ header with sequence definitions

To use the compiled output in your driver, include <basename>.hpp.