Skip to content

01 · Advanced CAPL Scripting

Level 1 Module 5 covered CAPL's event model — on start, on message, on timer, output(). This module builds on that with the constructs you need once a script grows past a toy demo: functions, arrays and structured data, signal-level access, environment variables, and the testcase/testfunction building blocks that Module 2 turns into full test modules.

About this module

All syntax below (function declarations, sysvar, arrays, strncpy and friends, @sysvar change handlers) is genuine, documented CAPL. As in Level 1, nothing here was compiled against a live CANoe instance — treat every snippet as a reviewed reference, not a captured run.

User-defined functions

CAPL supports ordinary functions, declared outside any event block, with C-like typing:

variables
{
  byte lastAliveCounter;
}

byte computeChecksum(byte b0, byte b1, byte b2, byte b3)
{
  // Simple additive checksum - many real ECUs use CRC8/CRC16 instead,
  // but the calling convention is the same either way.
  return (b0 + b1 + b2 + b3) & 0xFF;
}

on message SensorNodeStatus
{
  byte calc;
  calc = computeChecksum(this.byte(0), this.byte(1), this.byte(2), this.byte(3));
  if (calc != this.byte(4))
  {
    write("Checksum mismatch: calc=%d, received=%d", calc, this.byte(4));
  }
}

Functions can return void, int, long, float, byte, char[], or a message type, and can take any of those as parameters. Unlike C, CAPL has no pointers and no dynamic memory — every array is fixed-size and declared up front, which matters for the buffer patterns below.

Arrays and byte buffers

Raw frame payloads are commonly handled as byte arrays, especially for UDS multi-frame reassembly or CRC calculations that need to walk the whole payload:

variables
{
  byte rxBuffer[64];
  dword rxLength;
}

on message DiagResponse
{
  dword i;
  rxLength = this.dlc;
  for (i = 0; i < rxLength; i++)
  {
    rxBuffer[i] = this.byte(i);
  }
  write("Captured %d bytes, first byte = 0x%X", rxLength, rxBuffer[0]);
}

this.byte(i) reads a raw payload byte by index regardless of whether a database defines named signals for the message — the fallback you reach for whenever you need generic frame access (diagnostics, unknown/unDBC'd traffic, checksum/CRC routines).

System variables (sysvars): state shared across the whole configuration

A system variable (sysvar) is a named, typed value that lives outside any one CAPL block and can be read or written from multiple nodes, panels, and test modules in the same CANoe configuration — CAPL's mechanism for state that needs to outlive a single script or be shared with, say, a Panel UI button.

variables
{
  // Declared in the configuration's system variable namespace,
  // referenced here as Namespace::VariableName.
}

on sysvar_update Diag::ResetRequested
{
  if (@Diag::ResetRequested == 1)
  {
    write("Operator requested a diagnostic reset via the panel.");
    @Diag::ResetRequested = 0;   // consume the request
  }
}

@Namespace::Name reads or writes a sysvar's current value; on sysvar_update fires whenever it changes, regardless of what wrote it. This is the standard way to let a human-facing panel button or another CAPL node trigger behavior in your script without a CAN message ever being involved.

Signal-level access vs. raw byte access

Once a database (DBC/ARXML) is loaded, CAPL gives you two ways to touch the same payload:

Access style Example When to use
Signal-level this.CoolantTemp Normal use — DBC scaling/offset applied automatically
Raw byte this.byte(0) Checksums, CRCs, generic/undefined payloads, bit-level manipulation

Mixing the two on the same message is fine and common — read a checksum byte raw while reading every other field by signal name.

String and diagnostic-ID formatting helpers

Building UDS requests (Level 1 Module 6) and log messages often needs string/byte-array conversion:

char logLine[128];

void logDtc(dword dtcCode, byte statusMask)
{
  snprintf(logLine, elcount(logLine),
           "DTC 0x%06X, status mask 0x%02X", dtcCode, statusMask);
  write(logLine);
}

elcount() returns the declared element count of an array — always preferred over a hardcoded buffer size, since CAPL arrays are fixed and a mismatched literal is a classic off-by-one bug source.

testcase/testfunction: the bridge to Module 2

CAPL code destined for a CANoe test module (not a plain simulation node) is organized differently — into testcase blocks with verdict calls like testStepPass()/testStepFail(). Module 2 covers this in full; the shape to recognize now:

testcase TC_CoolantTempInRange()
{
  testStep("Check coolant temperature signal is within calibration range");
  if (SensorNodeStatus::CoolantTemp >= -400 && SensorNodeStatus::CoolantTemp <= 1500)
  {
    testStepPass("CoolantTemp within [-40.0, 150.0] C");
  }
  else
  {
    testStepFail("CoolantTemp out of range");
  }
}

Cheat sheet

Element Purpose
returnType name(params) { } User-defined function, C-like calling convention
byte buf[N]; Fixed-size array; no dynamic memory in CAPL
this.byte(i) Raw payload byte access, independent of database signals
@Namespace::Var Read/write a system variable
on sysvar_update X Fires when sysvar X changes, from any source
elcount(arr) Declared element count of an array — use instead of literals
snprintf(buf, elcount(buf), fmt, ...) Safe formatted string building
testcase, testStepPass/Fail Entry points for CANoe test modules (Module 2)

How It Actually Works: why sysvar updates queue instead of racing

on sysvar_update looks like it should behave the same as on message — an event fires, a handler runs — and structurally it does, because Module 5's single-threaded, run-to-completion event queue (Level 1) governs sysvars exactly the same way it governs messages and timers. But sysvars have one behavior that trips people up precisely because they don't travel over the bus: a sysvar write takes effect in CANoe's shared variable store immediately and synchronously, while the resulting on sysvar_update notification to every other CAPL block watching that variable is queued as a separate event, delivered in FIFO order alongside every pending message and timer event.

The practical consequence: if your node script and a Panel button both write to Diag::ResetRequested in the same instant (a human clicking the panel while your handler is also about to reset it), there's no race in the multithreaded sense — one write happens, then the other, strictly in event-queue order — but the value a late on sysvar_update handler reads via @Diag::ResetRequested is whatever the variable holds at the moment that specific handler runs, not at the moment the write that triggered it occurred. If a second write lands on the same sysvar before the first update notification is processed, only one on sysvar_update event may end up representing two logical changes (CANoe coalesces rapid updates rather than guaranteeing one notification per write) — so a script relying on sysvar updates to count discrete events (rather than reflect current state) will silently undercount under bursty conditions. This is exactly why the reset-request pattern above "consumes" the flag by writing it back to 0 inside the handler: it converts an edge-triggered assumption into explicit level-based state management, sidestepping the coalescing behavior entirely rather than depending on one-notification-per-write, which CANoe never actually promises.

Exercise

Write a CAPL function bool isValidAliveCounter(byte previous, byte current) that returns true if current is exactly previous + 1 modulo 16 (matching the 4-bit alive-counter pattern from Level 1 Module 5), and false otherwise. Then write an on message handler for SensorNodeStatus that calls this function every time a frame arrives, using a variables-block byte to remember the previous value, and logs a raw byte dump of the whole payload via this.byte(i) in a loop whenever the counter check fails. Finally, explain in a short paragraph why raw byte access (rather than signal-level access) is the right choice for that failure-diagnostics log line specifically.