C⏚ v2.0.0Updated 2026-05-12·Language

Tasks

A task is an atomic sequential entity. It has private state, ports, optional constants, and the special functions setup and loop. Every clock cycle, the task executes part of its body.

A task is synchronous by default: clock and reset are implicit. Combinational tasks and multi-clock tasks are configured through the properties block.

Anatomy

A task body holds four kinds of declaration:

task T {
  // 1. State variables - persist across cycles, become registers.
  short i = 0;
  bool started = false;
 
  // 2. Constants - compile-time values, explicit const required.
  const short LENGTH_PRE = 6;
 
  // 3. Ports - interface to the outside.
  in  push u8 data;
  out push bool done;
 
  // 4. Functions - including the special setup() and loop().
  void setup() { /* runs once after reset */ }
  void loop()  { /* runs every cycle, repeatedly */ }
}

Constants inside a task require the explicit const keyword. In a bundle the keyword is optional because bundle members are implicitly constant.

setup is optional. If declared, it runs once after reset, then control passes to loop. loop runs repeatedly: after the body finishes, it starts again.

Cycle-accurate execution

Statements inside setup or loop execute sequentially within the same cycle. A cycle break ends the current cycle and starts the next. The compiler turns each task into a finite-state machine where each state is one execution rule, and each rule takes exactly one cycle.

Explicit cycle breaks

Two statements introduce cycle breaks explicitly.

StatementEffect
fenceEnd the current cycle, resume on the next.
idle(n)End the current cycle and stay idle for n cycles before resuming. n is a compile-time constant.

A task with a fence between two writes takes two cycles per loop iteration:

task TwoCycle {
  out push u8 value;
  u8 v;
 
  void loop() {
    value.write(v);      // cycle 1
    fence;
    v = v + 1;           // cycle 2
  }
}

Implicit cycle breaks

The compiler inserts a cycle break automatically in three situations:

  1. Reading the same port twice - the second read must happen on a later cycle.
  2. Writing the same port twice - same reason.
  3. Before each iteration of a sequential loop, so each iteration consumes at least one cycle.

This for loop runs for 16 cycles, with one msg.read() per cycle and a final cycle to leave the loop:

for (t = 0; t < 16; t++) {
  W[t] = msg.read();
}

The compiler refuses to schedule two operations in the same cycle when an implicit break would otherwise be required. There is no hidden parallelism.

Port synchronization

A read() on a push, stream, or confirm port blocks: the surrounding rule defers until the data arrives. Reading several such ports in one rule is conjunctive, so the rule fires only when every read completes in the same cycle. port.available() tests for data without consuming it.

in push u3 a, b;
out u6 product;
 
void loop() {
  product.write(a.read() * b.read());   // fires when a AND b valid
}

See Declarations for the full qualifier reference.

Parameters

A task is parameterized by its constants. Declare a const with a default value, then override it per instance. Each distinct set of argument values produces its own specialized module.

task Widget {
  const int W = 8;        // a parameter, default 8
 
  out uint<W> q;          // its width depends on W
  uint<W> reg;
 
  void setup() { reg = 0; }
  void loop() { reg = (uint<W>)(reg + 1); q.write(reg); }
}

Use a parameter anywhere a compile-time value is allowed: a type width (uint<W>), an array size (uint<8> mem[W]), or any constant expression. A network overrides parameters by name when it instantiates the task.

w8  = new Widget();          // W defaults to 8  -> uint<8>
w16 = new Widget({W: 16});   // W = 16           -> uint<16>

The compiler generates one specialized module per distinct argument set, so w8 and w16 are independent hardware. An unspecified parameter keeps its default. This is the same mechanism the standard library uses for size and width on its FIFOs and RAMs.

A worked example, parameterized by depth:

task Accumulator {
  const int DEPTH = 4;
 
  uint<8> mem[DEPTH];      // array sized by the parameter
  out uint<32> total;
 
  void setup() {
    uint<8> i;
    for (i = 0; i < DEPTH; i = (uint<8>)(i + 1)) { mem[i] = i; }
  }
 
  void loop() {
    uint<32> sum = 0;
    uint<8> i;
    for (i = 0; i < DEPTH; i = (uint<8>)(i + 1)) { sum = (uint<32>)(sum + mem[i]); }
    total.write(sum);
  }
}
 
acc4  = new Accumulator();              // DEPTH 4
acc16 = new Accumulator({DEPTH: 16});   // DEPTH 16, a 4x-larger array

Angle-bracket shorthand

Parameters can also be declared and supplied with the familiar angle-bracket syntax. Declare the formals after the task name, then pass them positionally when you instantiate:

task Cell<int W = 8, int EXPECT = 0xFF> {
  uint<W> reg;
  void setup() { reg = (uint<W>)EXPECT; }
  ...
}
 
c4 = new Cell<4, 0xF>();   // W = 4, EXPECT = 0xF
c8 = new Cell<8, 0xFF>();  // W = 8, EXPECT = 0xFF
cd = new Cell();           // both defaults: W = 8, EXPECT = 0xFF

This is exactly the same parameter mechanism - task Cell<int W = 8> is shorthand for a const int W = 8 parameter, and new Cell<4>() is shorthand for new Cell({W: 4}). The two forms are interchangeable; you can even mix them (new Cell<4>({EXPECT: 0xF})), and a named argument wins if it overlaps a positional one. Positional arguments bind to the formals in declaration order; supplying fewer is fine (the rest keep their defaults), but supplying more than the task declares is an error.

Type arguments and parameter defaults are constant arithmetic expressions (+, -, *, /, %). Comparison and shift operators (<, >, <<, >>) are not allowed inside the angle brackets - they would collide with the closing >; precompute the value into a const if you need them.

Parameterized ports

A port's width can be a parameter, so the same task specializes into instances with different interface widths. Wire each producer to a consumer of the same width:

task Producer {
  const int W = 8;
  out push uint<W> o;
  void setup() { o.write((uint<W>)0xFF); }
}
 
task Consumer {
  const int W = 8;
  in push uint<W> i;
  void setup() { uint<W> v = i.read; }
}
 
network Pipelines {
  p4 = new Producer({W: 4});   // 4-bit port
  c4 = new Consumer({W: 4});
  p8 = new Producer({W: 8});   // 8-bit port
  c8 = new Consumer({W: 8});
  c4.reads(p4.o);              // 4-bit -> 4-bit
  c8.reads(p8.o);              // 8-bit -> 8-bit
}

Connected ports must have the same width. Wiring a 4-bit output to an 8-bit input - for example a generic instance specialized to the wrong width - is an error, not a silent zero-extend or truncation. The check applies to fixed-width ports too (u8 into uint<4>).

For widths derived from a size, use sizeof, which returns the number of bits needed to hold a value (the clog2 you would write in other HDLs):

const int DEPTH = 16;
const int AW    = sizeof(DEPTH - 1);   // 4 - bits to index 16 entries
out push uint<AW> addr;                // a 4-bit address port

Rules and limits

  • A parameter must have a default. An uninitialized const is rejected.
  • An argument key must name a const of the task. A misspelled key like {Wdith: 16} is an error, not a silent fallback to the default.
  • Arguments may be named ({W: 16}) or positional (Widget<16>); see the angle-bracket shorthand above. A named argument wins if it overlaps a positional one.
  • Connected ports must have matching widths. A width mismatch on a connection is an error, not an implicit resize.

External tasks

An external task declares a signature in C⏚ but provides its implementation in Verilog, VHDL, or another HDL. The C⏚ compiler instantiates and wires it like any other task, but emits no code for its body.

package com.acme;
 
task Queue {
  properties {
    clocks: ["din_clock", "dout_clock"],
    implementation: {
      type: "external",
      file: "../../../rtl/Queue.v",
      dependencies: ["../../../rtl/Ram.v"]
    }
  }
 
  const int depth = 2, width = 16;
 
  in  push uint<width> din;
  out push uint<width> dout;
  out push bool ack;
}

External tasks have:

  • No setup, no loop, no function bodies.
  • Constants that are passed through as instantiation parameters.
  • An implementation property pointing at the HDL file and any dependencies.

The compiler emits scripts that include the listed dependencies in declaration order, before the task itself. Paths are absolute or relative to the folder containing the .cg file.

Instantiation overrides the constants:

queue = new Queue({depth: 32, width: 8});

Next: Networks for composing tasks into hierarchies.