An open-source toolchain and development platform for the Xilinx XC2064, the first FPGA.
It takes a Verilog-like HDL and transforms it all the way to a real, simulatable configuration of the chip: parsing, synthesis, optimisation, technology mapping, packing, placement, routing, and finally a bit-accurate simulation of the placed-and-routed fabric.
This project uses uv for package management. If you don't have uv installed, you can install it using the official uv install guide.
Clone the repository and navigate to the project directory:
git clone https://github.com/rates37/openxc2064.git
cd openxc2064Install the project dependencies:
uv syncRun the test suite with pytest:
uv run pytestTo run Python scripts in this project with the correct environment:
uv run python <script.py>To run an interactive Python repl with the correct environment:
uv run python
>>> from openxc2064 import * HDL source
│
┌───┴─────────────────────── FRONT END ──────────────────────────┐
│ parse -> elaborate -> synthesise -> optimise │
│ -> lower -> optimise -> map -> pack │
└───┬────────────────────────────────────────────────────────────┘
▼
packed netlist CLBs + IOBs joined by logical nets
│ (basically a description of what exists, not where it sits)
│
┌───┴─────────────────────── BACK END ───────────────────────────┐
│ DesignView → AnnealingPlacer → PathFinderRouter │
└───┬────────────────────────────────────────────────────────────┘
▼
DeviceConfig enabled PIPs, switch-matrix connections,
│ logic-cell muxes/LUTs, IO bank modes
│
├──▶ verify_equivalence RTL netlist vs routed fabric
├──▶ FabricSimulator run the routed design in Python
├──▶ config.save(...) JSON → the browser web simulator
└──▶ Todo: generate bitstream, program real chip
The whole flow has been combined into one call:
from openxc2064 import build
HDL = """module counter(input clk, output reg [3:0] count);
always : seq @(posedge clk)
count = count + 1;
endmodule
"""
config, placement, report = build(HDL, "counter", seed=0)
# config is XXX
# placement is XXX
# report is XXXbuild chains the front end and the back end. If you want the two halves
separately (for example to inspect or cache the packed netlist):
from openxc2064.toolchain import compile_hdl_to_packed
from openxc2064.pnr import place_and_route
packed = compile_hdl_to_packed(HDL, "counter")
config, placement, report = place_and_route(packed, seed=0)Pipeline composition can be seen in openxc2064/toolchain.py.
A small Verilog-like language, defined by the grammar in
hdl/grammar.lark.
module alu(input [3:0] a, input [3:0] b, input sel, output reg [3:0] y);
always : comb
if (sel)
y = a + b;
else
y = a ^ b;
endmoduleSupported constructs:
| Category | Syntax |
|---|---|
| Ports | input, output, inout, optional reg, optional [msb:lsb] |
| Declarations | wire x;, wire [7:0] bus;, reg q;, reg [3:0] count; |
| Continuous assign | assign y = expr;, assign y[2] = ..., assign y[3:0] = ... |
| Combinational block | always : comb <statement> (sensitivity inferred from the body) |
| Sequential block | always : seq @(posedge clk) <statement> (also negedge) |
| Statements | begin ... end, if (...) ... else ..., = and <= |
| Operators | || && == != << >> + - * / | ^ & and unary - ! ~ |
| Literals | 42, 4'b1010, 8'hFF, 8'o17, 8'd9, a sized literal needs its width (so 'hFF doesn't parse) |
| Instances | adder #(.W(4)) u0 (.a(x), .b(y), .sum(s)); named params and ports |
| Comments | // line and /* block */ |
Two deliberate departures from Verilog: always blocks are explicitly tagged : comb or : seq rather than inferred from the sensitivity list, and module instantiation requires named parameter/port connections (no positional instantiation allowed).
Each phase below is shown with the real numbers produced by the 4-bit counter above, so you can watch the design change shape as it goes through the different forms of representation/implementation.
from openxc2064.hdl import parse_hdl
ast = parse_hdl(HDL) # -> [Module(name='counter', ...)]A lark grammar produces a parse tree which an ASTBuilder transformer rewrites into the typed AST nodes in hdl/ast_nodes.py — Module, Port, Assign, AlwaysSeq, BinOp, and so on. This stage is purely syntactic: it doesn't know whether count is declared, or how wide it is.
from openxc2064.synthesis import HDLElaborator
library = HDLElaborator(ast).get_library()Elaboration walks each module and records every signal's width, direction, and reg-ness into a SymbolInfo table, then validates the design semantically. All of the following raise HDLValidationError here: undeclared identifiers (in expressions, assignment targets, or a sensitivity list), duplicate port/wire/reg declarations, assigning to an input, continuous-assigning to a reg, procedurally assigning to something not declared reg, multiple drivers on one bit, unknown instantiated modules or port names, and circular module instantiation. Catching these early means every later stage can assume a well-formed design. The result is a library of elaborated modules, so instantiation can be resolved by name.
from openxc2064.synthesis import Synthesiser
netlist = Synthesiser(library).synthesise("counter")
# nodes=4 nets=4The synthesiser flattens the module hierarchy (instances are inlined, with parameters substituted) and converts behavioural code into a graph of Nodes connected by Nets (you can see the relevant types in the RTL netlist in synthesis/rtl_nodes.py). Expressions become LogicGate nodes that are still word-level: a single ADD node carries the whole 4-bit addition, and always : seq blocks become DFF nodes. Nets still have a width.
from openxc2064.synthesis import Optimiser
netlist = Optimiser().optimise(netlist)Optimiser.optimise runs three passes in a loop until the netlist converges:
- dead-code trimming: reverse-reachability from the outputs -> anything that cannot influence an output is removed from the graph
- constant folding: gates with fully-constant inputs are replaced by a
Constant, which then propagates - logic simplification — algebraic identities (
a & 0 → 0,a | 0 → a,a ^ 0 → a, double negation, etc.).
Running to a fixed point matters because each pass feeds the others: folding a constant makes a gate dead, deleting it makes its driver dead, and so on.
You can see a more detailed description of this phase here.
from openxc2064.synthesis import LoweringPass
lowered = LoweringPass().run(netlist)
# nodes=195 gates=157The FPGA has no adders — only 3-input lookup tables. Lowering expands every multi-bit net into an array of 1-bit nets and every word-level operator into a gate-level implementation: ADD becomes a ripple-carry chain of XOR/AND/OR, comparisons become XNOR trees, shifts become rewiring, and narrow operands are zero-extended with constant ties. The 4-node counter explodes into 195 nodes, 157 of them gates.
lowered = Optimiser().optimise(lowered)
# nodes=11 gates=6This second pass is not redundant. Lowering deliberately emits structure it knows is wasteful, such as zero-extension ties, ripple-carry seed bits (carry_in = 0), constant operands from literals, because emitting it uniformly is simpler than special-casing. Re-running the optimiser folds all of it away: the counter collapses from 195 nodes to 11, and 157 gates to 6. Skipping this pass costs real silicon and since the XC2064 is so limited in this aspect, this is super important.
from openxc2064.mapping.mapper import GreedyMapper
mapped = GreedyMapper(k_max=3).run(lowered)
# luts=5The XC2064's function generators take at most three inputs, so the mapper greedily grows cones of logic backward from each gate, absorbing predecessors while the cone's input count stays <= 3, then assigns each cone into a single LUT node with a computed 8-bit truth table. Where a gate feeds several cones, absorb_multi_sink_gates (default True) duplicates its logic into every consumer, trading a wider fan-out on the cone's inputs for fewer LUTs overall; setting it False cuts at the shared net instead and gives that gate its own LUT. The counter's 6 gates become 5 LUTs.
from openxc2064.mapping.packer import GreedyPacker
packed = GreedyPacker(max_clbs=64).run(mapped)
# clbs=4 iobs=5A CLB is not just "two LUTs" in a grouping. It holds an F and a G function generator, one flip-flop, and critically, the two LUTs must share the block's four physical input pins A/B/C/D through a fixed network of multiplexers. Packing pairs LUTs (preferring a LUT with its own DFF, and pairs that already share inputs), then solves the pin-assignment puzzle: route_lut tries permutations of the cone's inputs against the A/B/C/D pins until it finds mux selects (sel_f_in1/2/3, sel_g_in1/2/3) that satisfy both LUTs simultaneously, and rejects the pairing if none exists. Top-level ports become IOB nodes bound to pads. Exceeding max_clbs raises CapacityError.
The result is the packed netlist: the front end's output, and the back end's input. It says what blocks exist and which wires connect them, but nothing about where on the chip or through which physical wires / switch matrices.
from openxc2064.pnr import DesignView
view = DesignView(packed)
# routable nets=6 clock net='clk[0]'DesignView reads the packed netlist once and extracts, for every routable net, its driver and sinks as (node, pin) terminals — (clb, 'X') or (iob, 'I') driving; (clb, 'A'..'D'), (clb, 'K') or (iob, 'O') consuming. Deliberately, terminals stay logical here. They only become fabric net IDs inside the router, once placement is known.
It also acts as another validation layer, and raises DesignError for the following:
- a constant driving a routed net (the fabric has no VCC/GND taps, so constants must have been folded away)
- a sink with no driver
- a net with multiple drivers
- more than one clock domain
- tri-state IOBs (bidirectional pads are not supported yet)
Nets that feed a K pin are flagged is_clock, which changes how both the placer and the router treat them.
from openxc2064.pnr import AnnealingPlacer
placement = AnnealingPlacer().run(view, fabric, seed=0)
# {'clb0': 'AH', 'clb1': 'BH', 'clb2': 'CH', 'clb3': 'DH'}Placement chooses a grid cell (AA..HH) for every CLB and a pad bank for
every IOB, minimising
cost = Σ over data nets of HPWL(net) − w_direct × direct_connect_hits
HPWL is the half-perimeter of the net's bounding box, which is a standard cheap proxy for wire length. The second term rewards placements that land a driver and its sink on a direct connect: a dedicated pip that wires a CLB's X/Y output straight into a neighbour's A/B/C/D pin, costing no general routing at all. A hit only counts when the sink's actual packed pin matches the pip's destination pin, so the reward reflects a route the router can really take. Clock nets are excluded from the cost, because the clock rides a dedicated global clock line whose length does not depend on position.
The annealer seeds a random placement, derives its starting temperature from the spread of sampled move deltas, then repeatedly swaps/relocates blocks, accepting uphill moves with probability random.Random with sorted iteration order, so placement is bit-identical across runs and PYTHONHASHSEED values.
w_direct has a single source of truth: score a placement with placer.cost(view, fabric, placement) so you always measure the objective that placer actually optimised.
Pad placement is free by default, which scatters buses across the die. Pass pins= to nail chosen ports to chosen banks — see Constraining pin assignment.
from openxc2064.pnr import PathFinderRouter
config, report = PathFinderRouter().run(view, placement, fabric)
# hops=36 iterations=1 longline_uses=7 direct_hits=3Routing turns each logical net into a driver -> sinks tree of concrete fabric hops. A net's tree grows by repeated Dijkstra from the entire current tree to the nearest unreached sink, so trunks are shared within a net for free. Edge costs favour local wires over the global long lines.
Between nets, fabric wires are exclusive. Two signals on one wire is a short. Rather than forbidding overlap outright (which makes routing order decide success), the router uses PathFinder negotiated congestion: the first iteration lets nets overuse wires, then every net touching an overused wire is ripped up and re-routed under present congestion penalties (which grow each iteration) plus history penalties (which accumulate permanently on contested wires). Nets that genuinely need a wire outbid nets that just prefer it, and the process repeats until no wire is shared or max_iterations is hit, at which point RoutingError names every contested wire.
Two fabric policies are enforced here. The dedicated clock/oscillator distribution nets are reserved: data nets may never enter them, while the clock net may use the clock line. And clock nets are routed first, so the clock claims its trunk before data congestion can complicate things. Hops are recorded in traversal order, because the drivers list in the output is the only carrier of true signal direction.
The RoutingReport carries net_hops, total_hops, iterations, longline_uses and direct_hits, which is useful feedback that can be used to tune the cost constants.
The router's output is a DeviceConfig: the complete programmable state of the chip: which PIPs are enabled, which switch-matrix pin pairs are connected and in which direction, each logic cell's mux selects and LUT truth tables, and each IO bank's mode. It is the same state the web simulator imports, so it serialises straight to JSON:
config.save("counter.json")
# keys: drivers, ioBanks, logicCells, pips, switchMatricesThe device model it is built against is Fabric: every wire and every programmable switch of the chip, loaded from JSON exported out of the web simulator's config files (the ground truth for connectivity). Fabric.load("xc2064_8x8") is the full device; xc2064_3x3 is a small fabric useful for fast and simpler testing.
from openxc2064.pnr import verify_equivalence
checks = verify_equivalence(packed, config, placement, clock_cycles=32)
# 64 comparisons, no mismatchThis runs the same stimulus through two independent simulators: RTLSimulator on the packed netlist (what the design means) and FabricSimulator on the routed DeviceConfig (what the chip does). It requires every output pad to agree after every step. Any disagreement raises EquivalenceError naming the pad, the step, and the input assignment.
Stimulus is exhaustive when the design has few data inputs (<= 8 by default -> all clock_cycles cycles and compares after each phase. Without that a sequential design would only ever see the couple of rising edges a vector sweep happens to produce, and would be poorly tested.
from openxc2064.simulator import FabricSimulator
sim = FabricSimulator(config)
assert sim.warnings == []
clk_bank = placement.iob_sites[clk_iob.id]
for _ in range(6):
for level in (0, 1):
sim.set_pad(clk_bank, level)
sim.step()
print(read_count(sim)) # 1, 2, 3, 4, 5, 6FabricSimulator executes the configuration natively on the fabric graph. Instead of iterating until values settle, construction compiles the config: enabled routing edges and active device functions become a net-level dependency graph, topologically sorted once, then flattened into a list of closures over a single integer array, so a step() is one allocation-free pass. Logic-cell semantics (LUT index order, output muxes, clock muxes, async set/reset precedence) mirror the web simulator's LogicCell.ts exactly.
Construction doubles as a router-bug detector: a net with multiple drivers or a register-free combinational loop raises FabricSimulationError, and floating nets that something actually reads are collected in warnings. An empty warnings list is a meaningful signal that the route is sound.
The saved JSON imports directly into the browser-based simulator in simulator/, where you can toggle pads by hand and watch signals propagate through the actual switch matrices:
cd simulator && npm run devSome reference designs are generated by:
uv run python -m openxc2064.device.demo_designsBy default the placer puts every top-level port on whichever pad bank minimises wire length. That is good for routing and bad for reading: an 8-bit counter's output bus ends up scattered around two edges of the die, in no particular order, which makes the result hard to follow in the web simulator or on real hardware.
PinConstraints optionally ties chosen pads to chosen banks. It is purely additive, pass nothing and placement behaves exactly as before.
from openxc2064 import build
from openxc2064.device import Fabric
from openxc2064.pnr import PinConstraints
fabric = Fabric.load("xc2064_8x8")
# the clk input will be on the AA_IO0 IOB:
pins = PinConstraints({"clk": "AA_IO0"})
# the cout output will be on 8 consecutive IOBs on the north face of the chip:
pins.assign_bus("count", fabric.pad_banks(edge="N")[4:12])
config, placement, report = build(COUNTER_HDL, "counter", fabric=fabric, pins=pins)The difference for the 8-bit counter:
| pad | unconstrained | pinned to the north edge |
|---|---|---|
count[0] |
FH_IO0 |
AB_IO0 |
count[1] |
GH_IO1 |
AB_IO1 |
count[2] |
HG_IO1 |
AC_IO0 |
count[3] |
HG_IO0 |
AC_IO1 |
count[4] |
HF_IO0 |
AD_IO0 |
count[5] |
HE_IO0 |
AD_IO1 |
count[6] |
HE_IO1 |
AE_IO0 |
count[7] |
HD_IO1 |
AE_IO1 |
Constraining pins costs nothing here, that route came out at 84 hops versus 99 unconstrained, but in general pinning trades routability for layout control, and an impossible set of pins will surface as a RoutingError.
Fabric.pad_banks(edge=...) lists pad-bearing banks in physical order, so a slice of it is a contiguous row of pads:
fabric.pad_banks() # all 58 banks, in pad order
fabric.pad_banks(edge="N") # ['AA_IO0', 'AA_IO1', ..., 'AH_IO3'] (20)
fabric.pad_banks(edge="S") # 16 banks, left to right
fabric.pad_banks(edge="W") # 11 banks, top to bottom
fabric.pad_banks(edge="E") # 11 banks, top to bottomEvery pad on this device sits on the perimeter. Corner cells (which own four banks rather than two) are counted as part of the north or south edge.
Pad names are the bit-level names the front end produces: a scalar port clk becomes the pad clk[0], and a bus count becomes count[0]...count[7].
assign("clk", bank): a bare scalar name is accepted when it is unambiguous, soclkresolves toclk[0]assign("count[3]", bank): pin one bit of a bus by its exact nameassign_bus("count", banks): pincount[i]tobanks[i], the usual way to lay a bus along a row.
Constraints can also be kept in a file, since to_json() / from_json() round-trip a plain {pad: bank} object:
Path("counter.pins.json").write_text(pins.to_json())
pins = PinConstraints.from_json(Path("counter.pins.json").read_text())A plain dict works anywhere a PinConstraints does:
build(..., pins={"clk": "AA_IO0"}).
- One clock domain per design: Multiple clock nets raise
DesignError - No tri-state / bidirectional pads:
inoutparses, but a tri-state IOB is rejected byDesignViewrather than silently mis-routed - No timing analysis: Placement and routing optimise wire length and congestion; there is no static timing model or
Fmaxfigure calculated - No bitstream generation yet: The flow ends at a
DeviceConfig(JSON), which the fabric simulator and web simulator both consume; emitting real XC2064 bitstream bits is not yet implemented - Cost constants are first guesses:
w_direct = 2and the router's long-line base cost were chosen by reasoning, not measurement.