Standard library
C⏚ ships a small library of built-in tasks for the building
blocks every hardware design needs. All built-ins live under the
std.* packages and are implementation-resolved by the compiler.
| Entity | Package | Purpose |
|---|---|---|
SynchronousFIFO | std.fifo | FIFO buffer, one clock domain |
AsynchronousFIFO | std.fifo | FIFO buffer, two clock domains (CDC) |
SynchronizerFF | std.lib | 1-bit clock-domain crossing |
SynchronizerMux | std.lib | N-bit clock-domain crossing |
MuxDDR | std.lib | Multiplex two SDR inputs into one DDR output |
DemuxDDR | std.lib | Demultiplex a DDR input into two SDR outputs |
SinglePortRAM | std.mem | One address port, read or write |
DualPortRAM | std.mem | Two address ports, independent reads and writes |
PseudoDualPortRAM | std.mem | One read port and one write port |
Divide | std.math | Signed divider for a variable divisor |
Multiply | std.math | Signed multiplier with a registered product |
SynchronousFIFO
package std.fifo;
task SynchronousFIFO {
const int size, width;
stream {
in unsigned<width> din;
out unsigned<width> dout;
}
}| Parameter | Required | Meaning |
|---|---|---|
size | yes | Capacity in elements |
width | yes | Data width in bits |
The producer writes to din unless the FIFO is full. The
consumer reads from dout unless the FIFO is empty. Both ports
are stream, so the FIFO back-pressures the producer when full
and stalls the consumer when empty.
Using a FIFO
Instantiate the FIFO in a network with new, passing size and
width as named arguments, then wire it with the positional
reads() form: fifo.reads(producer.dout) connects the
producer's output to the FIFO's din, and consumer.reads(fifo.dout)
connects the FIFO's dout to the consumer's input.
network Pipe {
fifo = new std.fifo.SynchronousFIFO({size: 16, width: 8});
producer = new task {
out stream u8 dout;
u8 n = 0;
void loop() {
n++;
dout.write(n); // stalls automatically when the FIFO is full
}
};
consumer = new task {
in stream u8 din;
void loop() {
u8 v = din.read; // stalls automatically until data is available
print("got ", v, "\n");
}
};
fifo.reads(producer.dout); // producer.dout -> fifo.din
consumer.reads(fifo.dout); // fifo.dout -> consumer.din
}The stream handshake is automatic: a dout.write blocks while
the FIFO is full and a din.read blocks while it is empty, so you
never have to poll full/empty yourself. Read each value with a
single din.read and let the loop come back around for the next -
the FIFO holds the next element until you read it, even across an
idle() or other work in between.
AsynchronousFIFO
Dual-clock FIFO for clock-domain crossing (CDC). din is clocked
by din_clock; dout is clocked by dout_clock.
package std.fifo;
task AsynchronousFIFO {
properties { clocks: ["din_clock", "dout_clock"] }
const int size, width;
stream {
in unsigned<width> din;
out unsigned<width> dout;
}
}| Parameter | Required | Meaning |
|---|---|---|
size | yes | Capacity in elements |
width | yes | Data width in bits |
Same back-pressure semantics as
SynchronousFIFO: writes stall when full,
reads stall when empty. Use this when the producer and consumer
run on independent clocks; otherwise prefer the synchronous
variant.
SynchronizerFF
Clock-domain crossing for a 1-bit signal.
package std.lib;
task SynchronizerFF {
properties { clocks: ["din_clock", "dout_clock"] }
const int stages = 2;
in bool din;
out bool dout;
}A shift register of stages flip-flops, clocked by dout_clock.
Increase stages for higher clock speeds or fault-tolerant
designs. The default of 2 is the textbook minimum.
SynchronizerMux
Clock-domain crossing for an N-bit signal.
package std.lib;
task SynchronizerMux {
properties { clocks: ["din_clock", "dout_clock"] }
const int width = 16, stages = 2;
in push unsigned<width> din;
out unsigned<width> dout;
}| Parameter | Default | Meaning |
|---|---|---|
width | 16 | Data width in bits |
stages | 2 | Synchronizer FF depth (see SynchronizerFF) |
How it works:
- A producer writes a new value to
din(apushport). - A 1-bit sync signal crosses from the input domain to the
output domain via the internal
SynchronizerFF. - The input value must stay stable during the crossing - no new
write to
dinuntil the crossing completes. - When the sync signal arrives in the output domain, the value
is sampled and presented on
dout.
MuxDDR
Multiplexes two single-data-rate streams into one double-data-rate
output: the rising input drives dout on the rising clock edge,
falling on the falling edge.
package std.lib;
task MuxDDR {
const int width;
in push unsigned<width> rising, falling;
out push unsigned<width> dout;
}| Parameter | Required | Meaning |
|---|---|---|
width | yes | Data width in bits |
m = new std.lib.MuxDDR({ width: 8 });
m.rising.reads(a.data);
m.falling.reads(b.data);DemuxDDR
The inverse of MuxDDR: splits one double-data-rate input into two
single-data-rate outputs, rising and falling.
package std.lib;
task DemuxDDR {
const int width;
in push unsigned<width> din;
out push unsigned<width> rising, falling;
}| Parameter | Required | Meaning |
|---|---|---|
width | yes | Data width in bits |
SinglePortRAM
package std.mem;
task SinglePortRAM {
properties { reset: null }
const int size, width, depth = sizeof(size - 1);
const bool writeShiftMode = false, addOutputRegister = false;
in unsigned<depth> address, push unsigned<width> data;
out unsigned<width> q;
}| Parameter | Required | Default | Meaning |
|---|---|---|---|
size | yes | - | Element count |
width | yes | - | Element width in bits |
writeShiftMode | no | false | false: write-through (new value on q). true: read-before-write (previous value on q). |
addOutputRegister | no | false | Adds an output register, delaying q by one cycle. Can help timing on FPGA. |
depth is computed from size; you don't set it.
| Port | Direction | Meaning |
|---|---|---|
address | in | Address to read or write |
data | in push | When valid, writes data at address; otherwise the RAM is in read mode |
q | out | Value read or written at the previous cycle's address |
Example
network N {
ram = new std.mem.SinglePortRAM({ size: 32, width: 128 });
ctrl = new task {
void loop() {
ram.address.write(8);
ram.data.write(13); // write 13 at address 8
ram.address.write(21);
ram.data.write(34); // write 34 at address 21
ram.address.write(8); // cycle 1: address arrives
ram.address.write(21); // cycle 2: address arrives
fence; // cycle 3: q reflects cycle-1 read
print("read @8 = ", ram.q.read());
print("read @21 = ", ram.q.read());
}
};
}A RAM read has one cycle of latency. Issuing two back-to-back reads (without a fence between them) lets the address pipeline amortize the latency: total time goes from 6 cycles to 4.
DualPortRAM
Two independent address ports, each with its own clock.
package std.mem;
task DualPortRAM {
properties { reset: null, clocks: ["clock_a", "clock_b"] }
const int size, width, depth = sizeof(size - 1);
in uint<depth> address_a, push uint<width> data_a;
out uint<width> q_a;
in uint<depth> address_b, push uint<width> data_b;
out uint<width> q_b;
}Parameters are the same as
SinglePortRAM. Ports ending in _a are
clocked by clock_a; ports in _b by clock_b. Each port
group behaves like a SinglePortRAM independently.
PseudoDualPortRAM
One read port, one write port, independent clocks. Smaller than a full DualPortRAM, throughput in between.
package std.mem;
task PseudoDualPortRAM {
properties { reset: null, clocks: ["rd_clock", "wr_clock"] }
const int size, width, depth = sizeof(size - 1);
in uint<depth> rd_address, wr_address, push uint<width> data;
out uint<width> q;
}| Port | Direction | Clock | Meaning |
|---|---|---|---|
rd_address | in | rd_clock | Read address |
wr_address | in | wr_clock | Write address |
data | in push | wr_clock | When valid, writes data at wr_address |
q | out | rd_clock | Value at the previous-cycle rd_address |
Reads and writes may run concurrently and at different rates.
std.math.Divide
A signed integer divider, for when the divisor is only known at
runtime. A constant divisor needs no built-in: x / 10 compiles to
a reciprocal multiply, and x / 8 to a shift. See
Expressions → Multiplicative.
package std.math;
task Divide {
properties { implementation: { type: "builtin" } }
const int width = 32;
const int use_hard = 0;
stream {
in int<width> a;
in int<width> b;
out int<width> q;
}
}| Parameter | Default | Meaning |
|---|---|---|
width | 32 | Operand and quotient width in bits |
use_hard | 0 | Which engine to build. See below. |
q is a / b, signed, truncated toward zero. b == 0 is
undefined, as it is for / in RTL.
The two engines. use_hard: 0 builds a portable sequential
shift/subtract divider: a shift register, one subtractor and a
small FSM, which synthesizes on any FPGA and takes roughly width
cycles per quotient. A power-of-two divisor is special-cased to a
single-cycle shift. use_hard: 1 emits a native single-cycle
$signed(a) / $signed(b) instead, which is only worth choosing on
parts that provide a hardware divider. On parts that do not, it
bloats the logic and caps your clock rate.
It back-pressures, so pace it. The ports are stream, because
a sequential divider is slow and unbuffered. It lowers a_ready
and b_ready while computing. A producer that writes every cycle
will overrun it and lose operands. Either put a FIFO in front of
it, or space the writes out past the latency:
network DivDemo {
properties { test: { terminate: "checker.finished" } }
source = new task {
out stream int<32> a;
out stream int<32> b;
const int<32> A[3] = { 100, -100, 42 };
const int<32> B[3] = { 7, 7, 6 };
u8 i;
void setup() {
for (i = 0; i < 3; i++) {
a.write(A[i]);
b.write(B[i]);
idle(40); // pace: the divider is busy ~width cycles
}
}
};
div = new std.math.Divide();
div.reads(source.a, source.b);
checker = new task {
in stream int<32> q;
bool finished;
u8 i;
const int<32> Q[3] = { 14, -14, 7 }; // note -100/7 == -14, not -15
void setup() {
for (i = 0; i < 3; i++) { assert(q.read() == Q[i]); }
finished = true;
}
};
checker.reads(div.q);
}The bytecode simulator computes a quotient in one cycle, so it will not show you an overrun. Cross-check a divider-fed design against generated HDL before trusting the timing.
std.math.Multiply
A signed multiplier that registers its product. A bare * is
already single-cycle and maps to a DSP block, so reach for this only
when a wide combinational multiply is capping your clock rate: the
registered product breaks that path so the tool can retime it into
the DSP's own pipeline registers.
package std.math;
task Multiply {
properties { implementation: { type: "builtin" } }
const int width = 32;
const int pwidth = width * 2;
stream {
in int<width> a;
in int<width> b;
out int<pwidth> p;
}
}| Parameter | Default | Meaning |
|---|---|---|
width | 32 | Operand width in bits |
pwidth | width * 2 | Product width. Full width, so no bit is lost. |
p is a * b, signed, with one cycle of latency. The handshake is
the same as std.math.Divide: p is held until the consumer takes
it, and the producers are back-pressured while a product is in
flight.
mul = new std.math.Multiply();
mul.reads(feeder.ma, feeder.mb);
sink.reads(mul.p);