irtl
v1.0.3
Published
IR for RTL in JavaScript
Readme
IR for RTL in JavaScript
irtl is a small intermediate representation (IR) for register-transfer-level
(RTL) hardware, written in JavaScript. You describe modules — their ports,
wires, registers, and logic — as plain JavaScript, assemble them into a
hierarchy, and emit either FIRRTL
or Verilog.
Install
npm install irtlconst irtl = require('irtl');A pre-bundled standalone build (build/irtl.js, exposed as irtl via
unpkg) is available for the browser.
Quick start
Build a module, wrap it in a circuit, and emit both targets:
const irtl = require('irtl');
const {input, output, wire, and, xor, or, buf, repeat} = irtl.elements;
const m = irtl.createModule('bar');
// ports
m.clk = input.Clock(); // input clk : Clock
m.rst = input(1); // input rst : UInt<1>
m.arst = input.AsyncReset(); // input arst : AsyncReset
m.inp1 = input(1);
m.inp2 = input(32);
m.out1 = output(11);
// wires and registers
m.tmp1 = wire(1);
m.tmp2 = {width: 8, clock: m.clk}; // register
m.tmp3 = {width: 16, clock: m.clk, reset: m.rst}; // sync reset
m.tmp4 = {width: 32, clock: m.clk, reset: m.arst, resetValue: 1}; // async reset
// literals and logic
m.lit1 = 5; // width inferred
m.out1 = and(m.inp1, m.tmp1, m.tmp2, m.tmp2); // variadic
m.tmp8 = xor(m.tmp3, or(m.tmp4, m.lit1, 15));
m.tmp3 = buf(m.out1);
m.out2 = repeat(m.tmp1, 5);
// wrap the module(s) into a circuit hierarchy
const circuit = irtl.createCircuit('top_mod', [m]);
console.log(irtl.emitVerilog(circuit));
console.log(irtl.emitFirrtl(circuit));Emitted Verilog:
// circuit top_mod
module bar (
input clk,
input rst,
input arst,
input inp1,
input [31:0] inp2,
output [10:0] out1
);
wire tmp1;
wire [2:0] lit1;
wire tmp8;
wire out2;
reg [7:0] tmp2;
reg [15:0] tmp3;
reg [31:0] tmp4;
assign lit1 = 3'd5;
assign out1 = (inp1 & tmp1 & tmp2 & tmp2);
assign tmp8 = (tmp3 ^ (tmp4 | lit1 | 4'd15));
always @(posedge clk or posedge rst) if (rst) tmp3 <= 16'd0; else tmp3 <= out1;
assign out2 = {5{tmp1}};
endmoduleEmitted FIRRTL:
circuit top_mod:
module bar:
input clk: Clock
input rst: UInt<1>
input arst: AsyncReset
input inp1: UInt<1>
input inp2: UInt<32>
output out1: UInt<11>
wire tmp1: UInt<1>
wire lit1: UInt<3>
wire tmp8: UInt
wire out2: UInt
reg tmp2: UInt<8>, clk
reg tmp3: UInt<16>, clk with: (reset => (rst, 0))
reg tmp4: UInt<32>, clk with: (reset => (arst, 1))
lit1 <= UInt<3>(5)
out1 <= and(inp1, and(tmp1, and(tmp2, tmp2)))
tmp8 <= xor(tmp3, or(tmp4, or(lit1, UInt<4>(15))))
tmp3 <= out1
out2 <= cat(tmp1, cat(tmp1, cat(tmp1, cat(tmp1, tmp1))))Concepts
Modules are Proxies
createModule(name) returns a Proxy. Assigning a property defines a signal;
reading a property returns a reference to that signal you can wire into logic.
The right-hand side of an assignment decides what the signal is:
| RHS | Result |
| --- | --- |
| input(w) / output(w) / wire(w) | a port or wire, optional bit width w |
| input.Clock() / input.AsyncReset() | a typed input port |
| {width, clock} | a register clocked by clock |
| {width, clock, reset[, resetValue]} | a register with reset (sync if reset is a plain input, async if the reset signal is AsyncReset) |
| a number | a literal; width is inferred as ceil(log2(value + 1)) |
| an operation, e.g. and(a, b) | combinational logic driving the signal |
Referencing an undefined property (e.g. m.foo before assignment) lazily
creates an Int signal, so signals may be used before they are formally
declared.
Builder API (explicit alternative)
irtl.build(module) wraps a module in an explicit, chainable, validating
facade over the same Proxy — both styles interoperate and emit identical RTL.
It is handy for generated code, and for tools (or LLMs) that prefer named
methods over Proxy assignment:
const {add} = irtl.elements;
const m = irtl.build(irtl.createModule('adder'))
.addInput('clk', {type: 'Clock'})
.addInput('a', {width: 8})
.addInput('b', {width: 8})
.addOutput('sum', {width: 9})
.addReg('acc', {width: 9, clock: 'clk'}); // clock by name or by signal
m.assign('sum', add(m.signal('a'), m.signal('b')));
// hierarchy without nested arrays:
const top = irtl.build(irtl.createModule('top')).instantiate(m);
const circuit = irtl.createCircuit('top', top); // accepts a builder or a treeThe facade validates as you go (addInput('1bad') and re-declaring a port both
throw actionable errors) and exposes introspection: hasPort, getPortWidth,
getPortDirection, getSignalKind, listInputs, listOutputs.
Serialization
irtl.serialize(circuit) returns a plain, cycle-free JSON object (the live IR
is full of back-references and cannot be JSON.stringify-ed directly). Useful
for snapshots, diffing, or feeding the design to other tools:
JSON.stringify(irtl.serialize(circuit), null, 2);Elements and operations
irtl.elements provides the signal constructors (input, output, wire)
and a set of variadic operation builders. Each operation returns a plain node
{op, items: [...]} that nests freely:
asUInt asSInt asClock asAsyncReset asReset cvt neg not
andr orr xorr nandr norr xnorr
land lor lnot
bits tail head pad
add sub mul div rem
lt leq gt geq eq neq
shl shr dshl dshr
and or xor xnor cat
mux
assert assume cover
repeat bufVariadic ops fold right: and(a, b, c) emits as and(a, and(b, c)) in FIRRTL
and (a & b & c) in Verilog. irtl.variadics re-exports the binary/arithmetic
subset for convenience.
Verilog operator mapping
Each op lowers to native Verilog rather than a function call:
| Category | Ops | Verilog |
| --- | --- | --- |
| arithmetic | add sub mul div rem | + - * / % |
| comparison | lt leq gt geq eq neq | < <= > >= == != |
| bitwise | and or xor xnor | & \| ^ ~^ |
| logical | land lor lnot | && \|\| ! |
| reduction | andr orr xorr nandr norr xnorr | &(x) \|(x) ^(x) ~&(x) ~\|(x) ~^(x) |
| unary | not neg | ~(x) -(x) |
| shift | shl shr dshl dshr | << >> (>>> when operand is signed) |
| casts | asUInt asSInt cvt | $unsigned(x) / $signed(x) |
| select / slice | mux bits head tail pad cat repeat | ? :, part-select, replicate, concat |
Signed right shift. shr/dshr emit an arithmetic shift ($signed(x) >>> n)
when the operand carries signed: true, otherwise a logical >>. FIRRTL infers
this from the SInt/UInt type, so its output is unchanged.
Ops without a FIRRTL primop. xnor, land, lor, lnot, and the
nandr/norr/xnorr reductions have no direct FIRRTL primop, so the FIRRTL
emitter lowers them to an equivalent primop tree — e.g. xnor(a, b) →
not(xor(a, b)), land(a, b) → and(orr(a), orr(b)), lnot(x) → not(orr(x)).
The Verilog emitter throws on any unrecognized op rather than emitting garbage.
Circuits, hierarchy, and plumbing
createCircuit(name, tree) assembles modules into an instance hierarchy. The
tree is a nested array where the first element is the parent module and the
rest are its children:
// r ─ a ─┬─ b ─┬─ c
// │ ├─ d ─ e
// │ └─ f ─ h ─ i
// └─ g
const [r, a, b, c, d, e, f, g, h, i] =
'r a b c d e f g h i'.split(' ').map(irtl.createModule);
f.sig = 100; // driven in f
e.foo = buf(f.sig); // used in e — different subtree
g.bar = buf(f.sig); // used in g — another subtree
const circuit = irtl.createCircuit('top_mod',
[r, [a, [b, [c], [d, [e]], [f, [h, [i]]]], [g]]]);When a signal is driven in one module and used in another, plumbing
(lib/plumb.js) automatically routes it through the hierarchy:
it finds the lowest common ancestor of the driver and each user, then inserts
the intermediate output/wire/input ports (named _<path>_<sig>) and instance
bindings needed to connect them. You write the intent; irtl generates the
port list.
API
| Export | Description |
| --- | --- |
| createModule(name) | Create a module (a Proxy); assign properties to define signals. |
| createCircuit(name, tree) | Build the instance hierarchy and run plumbing; accepts a nested-array tree or a build(...) root. |
| build(module) | Wrap a module in the explicit, validating builder facade. |
| serialize(circuit) | Convert a circuit to plain, cycle-free JSON. |
| elements | Signal constructors (input, output, wire) and operation builders. |
| variadics | Arithmetic/logic operation builders (subset of elements). |
| emitVerilog(circuit) | Render the circuit to Verilog RTL (string). |
| emitFirrtl(circuit) | Render the circuit to FIRRTL (string). |
| plumb(mods) | Lower-level pass that inserts cross-module routing (run by createCircuit). |
| identity | Symbol used internally to unwrap a module Proxy to its target. |
| version | Package version. |
Development
npm test # eslint + node:test with coverage
npm run bundle # esbuild standalone browser bundle -> build/irtl.jsTests live in test/ and double as worked examples — test/emit.js covers a single feature-rich module, test/hier.js covers hierarchy and plumbing across several topologies, and test/builder.js exercises the builder facade, validation, and serialization.
