This tutorial walks you from zero to a working, custom element that you
write, compile, and run in the software emulator — no FPGA required. By
the end you will have built SeqStamp, an element that stamps a running
sequence number onto every packet, and wired it into a two-port
topology.
If you have not built the toolchain yet, do
getting_started.md first (steps 1–4). Everything
below runs at verification level L2 (software emulator), so you only
need cmake + gcc-11.
OpenClickNP borrows its model from Click and the ClickNP paper. Five terms cover almost everything:
- Flit — the unit of data on every wire between elements. It is a
fixed 64-byte struct (
openclicknp::flit_t, seeruntime/include/openclicknp/flit.hpp). Bytes 0–31 are packet payload; byte 32 holds the flags below; the rest is metadata you can use freely. - sop / eop — start-of-packet and end-of-packet flags on each
flit (
f.sop(),f.eop()). A packet larger than 32 bytes spans several flits: the first hassopset, the last haseopset. A one-flit packet has both. Per-packet logic keys offsop/eop; per-flit logic ignores them. - Lanes — the 64-byte flit is also addressable as four 64-bit
lanes via
f.get(lane)/f.set(lane, value)forlanein0..3. Lanes 0–3 overlap the payload bytes; elements use them to pass small metadata (a hash, a verdict, a next-hop) alongside or instead of payload. There is no enforced schema — communicating elements agree by convention which lane carries what. - Ports & port masks — an element declares
<N_in, N_out>ports. Inside.handleryou test/read inputs and write outputs by 1-based index using thePORT_1 … PORT_8masks. The handler returns a mask saying which inputs to wait on next iteration. - Signal RPC — the optional
.signalblock is a host-callable control hook (rule installs, counter reads, resets). An instance marked@in the topology is host-controllable.
Two edge kinds connect instances: A -> B is lossless (the producer
stalls when the channel is full); A => B is lossy (the producer
drops on full — used for taps that must never backpressure the datapath).
A "design" is any directory containing a topology.clnp. The build
scripts take a path, so it can live anywhere:
mkdir -p my_designs/SeqStamp
cd my_designs/SeqStampCreate SeqStamp.clnp next to where the topology will live. The compiler
resolves import first relative to the importing file, then against the
-I search path — so a local element needs no special flags.
// SeqStamp.clnp
// Stamp a per-packet sequence number into metadata lane 3.
.element SeqStamp <1, 1> {
.state {
// Persistent across iterations. Plain C++17.
uint64_t seq; // next sequence number to assign
}
.init {
_state.seq = 0; // runs once at launch
}
.handler {
// Runs every iteration. Do nothing unless input 1 has a flit.
if (test_input_port(PORT_1)) {
openclicknp::flit_t f = read_input_port(PORT_1);
if (f.sop()) { // first flit of a packet
f.set(3, _state.seq); // write seq into lane 3
_state.seq++;
}
set_output_port(1, f); // forward the (modified) flit
}
return PORT_1; // wait on input 1 next time
}
.signal (uint cmd, uint param) {
// Host RPC. Report the count; cmd==1 resets it.
outevent.lparam[0] = _state.seq;
if (cmd == 1u) _state.seq = 0;
}
}Three things to notice:
.state/.init/.handlerare plain C++17 bodies. The compiler passes them through opaquely to every backend (SW emu, SystemC, HLS, Verilator) — the same source becomes hardware. That is why you write ordinary C++ but avoid pointers/recursion/unbounded loops that won't synthesize.read_input_port(PORT_1)both returns the flit and marks the port consumed;set_output_port(1, f)enqueues on output 1.- The handler returns a port mask. Returning
PORT_1means "next iteration, only wake me when input 1 has data." Multi-input elements returnPORT_ALLor a computed mask.
See language.md for the full list of .handler
built-ins (input_ready, clear_input_ready, last_output_failed, …).
Create topology.clnp in the same directory. We forward ToR→NIC through
SeqStamp, and pass the reverse direction straight through with the
stock Pass element:
// topology.clnp
import "core/Pass.clnp"; // resolved via -I .../elements
import "SeqStamp.clnp"; // resolved relative to this file
SeqStamp :: stamp @ // '@' = host-controllable (signal RPC)
Pass :: rev
tor_in -> stamp -> nic_out
nic_in -> rev -> tor_outtor_in/tor_out and nic_in/nic_out are pseudo-elements that map to the
two QSFP28 cages on hardware and to packet sources/sinks in the emulator.
From the repo root:
./scripts/sim/run_emu.sh my_designs/SeqStampThis compiles topology.clnp to the SW-emu backend, builds it against
the runtime, and runs it. You should see the emulator launch, process
flits, and exit cleanly. Each packet that crosses ToR→NIC now leaves with
an incrementing value in lane 3, and stamp's signal RPC reports how many
packets have been stamped.
To inspect generated code, look in build/SeqStamp/generated/.
Library elements ship with a per-element test under tests/elements/
that replays canned flits and asserts on outputs (plain assert() +
exit code — no external framework). If you promote your element into
elements/<category>/, add tests/elements/test_SeqStamp.cpp and
register it in tests/elements/CMakeLists.txt; ctest will then cover
it. See CONTRIBUTING.md for the checklist.
language.md— full.clnpreference: element groups,*depthchannel overrides, lossy edges,.timing { ii = N; }.architecture.md— how a.clnpgraph becomes six different backends.examples/— 40+ worked designs, each with its own README. Good next reads:IP_Forwarding(lookup tables),Firewall(host rule install),RateLimiter(token-bucket state).