TxHDL: a hardware description language that is a Rust library

#Rust#FPGA#RISC-V#TxHDL#auto

For the past while, Dragiša Janković and I have been working on a hardware description language (HDL). We were worried about the fact that the mainstream HDLs seemed too low level (hi, Verilog!) to answer the challenges of modern design, and that high level synthesis (HLS) answered a different problem statement, the rapid prototyping and HDL crisis if you will.

We decided to pull a XKCD and build our own, encouraged by the fact that we can solicit help from LLMs to prove out our ideas quickly and relatively painlessly. We ended up designing two languages independently (not one, Bob, two!). We then tried to merge them somehow, and Rust kinda fell out of that work.

What does that mean? I hear you ask. You write a design as a Rust program. From there, everything else, the simulation, the Verilog and VHDL output, and the checks, comes from a Rust library and a few macros.

We have a five-page paper about the whole thing if you just want a quick look. We have some colorful images there too. If you want to dig in, we have 300+ pages of documentation and history to entertain you with, and a GitHub repo for you to dig through.

We named it TxHDL. I’d have it probably called either RHDL, or RustHDL, but both of those names were already taken.

So what? I hear you ask. I go into details below, but you can write a design in TxHDL today, and place it in hardware today, and it will work today.

To prove that we have a 32-bit RISC-V core written in it, and we ran it on an actual Artix-7 FPGA at 100 MHz. This is about typical what I’d expect of a design of this particular shape, without advanced optimization.

You can retrace these steps today: we don’t just throw some files over the wall and let you figure it out. The entire repository has a hermetic build system as a foundation. With some unavoidable prepwork due to licensing concerns, you can retrace those steps today and check out the result.

What we did not write

If you have used a hardware description language, you know the usual shape of the tool: a parser, a type checker, a module system, a package manager, and an editor plugin, each written from scratch. We did not write any of those.

We started from a wish for a HDL that would address our specific qualms. We decided to create something like a specification independently then merge into a consistent story. The original idea was to build out everything the language needed. Which is why we didn’t really even begin since even assisted building of something like this, still is not trivial.

An insight came out of the attempt to take our two specifications, and merge them into a consistent story. We found that our specs are mostly not overlapping, just because both of us had different specific issues front-and-center in our respective proposals.

We needed to reconcile those, and luckily because of how the original drafts came out, it wasn’t too hard to do. Except, what came out was suspiciously close to Rust. I then realized that we could lean more into Rust and that there’s a promise for getting multiple uses out of plain Rust source. We then started pushing our early spec to get closer and closer to plain Rust. We removed from it every construct that Rust already has, until only a library remained.

In the end, in TxHDL, a unit is just a struct. Registers are fields, and behaviour is an async fn with one loop in it. Wait for a clock edge, read the state, drive the outputs. Have a runtime library which knows how to do it, and you already have a simulator.

The Rust compiler is the type checker of the language. If you connect two drivers to one wire, you get a move error. If you cross clock domains without a synchronizer, you get a type error. A combinational loop shows up as a cycle in what the loop body reads.

All these goodies simply fell out of how rustc works.

One source, three forms

The same source file is used in three ways.

You can run it. The library contains a small executor with a stated model of time. You run the design as an ordinary Rust program and get a waveform out, in FST or VCD.

You can lower it into a “real” HDL. “Lowering” is a fancy name for producing something less abstract from something more abstract.

An attribute on the unit, #[lower], reads the run loop and produces Verilog and VHDL from it. There is a catch: the attribute does not reinterpret Rust. It reads a subset that is also plain Rust. You can not synthesize anything outside that subset. We find that this is quite acceptable, actually.

You can cross-check everything. We don’t throw things over the wall and hope for the best. The build runs the netlist under nvc and under Verilator, replaying the trace that the Rust simulation wrote and comparing every register and every output at every tick.

A design that passes is guaranteed to have the same behaviour in all three forms.

You can also put a foreign Verilog or VHDL module into a design as a unit and run it in the same simulation, with Verilator in process or with nvc as a child process. This exists because your new designs do not live in a vacuum. You must expect having to interact with “regular” hardware, expecting the entire world to migrate to something we cooked up a few days ago is just not realistic.

Actually works on actual hardware

This might be the strongest argument in favor of the embedding approach. It is all well and good to play around with languages in abstract machines. Where the pudding really meets the eating is, can you actually build something worthwhile out of it.

I was somewhat surprised that the answer to the proverbial pudding question is a “yes”. And we don’t just offer you a Blinky design to admire. We built you an entire CPU and only then put a Blinky and a serial port into it.

The largest design so far is Vreteno, an actual CPU. It is a three-stage RV32IM core with machine-mode traps, an interrupt line, and a bus on which the data memory, a timer and a serial port are units of their own.

We check it in lockstep against a reference model of the instruction set, on a demonstration program and on sixty-four random ones, and we check its netlist against its own trace.

Here are the opening cycles of the demonstration program, drawn by the build from the trace the Rust simulation wrote. Reset, then one instruction per edge with the fetch counter running ahead, and the writeback decoded into its register and its value:

Vreteno's opening cycles:
    clock, reset, program counter, instruction, done, rd, val and halt
    over the first thirty-two ticks
Vreteno's first cycles, from its trace: reset, then an instruction per edge, and the writeback decoded into its register and value.

We then pass this on to Vivado to have it see some real action. The Vivado project synthesizes, places and routes the core at 100 MHz. The design takes about two thousand LUTs, with the data memory in block RAM, which means it’s far from rivaling anything worthwhile. I think, however, the nontriviality of it serves as the proof of the concept.

Vivado built a bitstream which we then shoved into an Alinx AX7A200 board with a watcher on the board’s serial port.

OK appeared on the port, the watcher then typed three bytes, the same three came back, and the LEDs lit up as the board top describes. Success!

Those six bytes on the line are the six bytes that the lockstep model records for the same program. We take that as proof: the language works on hardware, from a Rust source to a board that actually runs.

The same bytes, in simulation, look like this. First the O going out on the serial line: the write reaches the port, the bit count loads ten, and the line carries a start bit, eight bits low first and a stop bit, four cycles each. The later requests are the program polling the status for the next byte:

A byte out on the serial
    port: the request into the port, the bit count, the shift register
    and the line
A byte out: the write into the port, ten bits to send, and the line's start bit, eight bits and stop bit, four cycles each.

Then the y coming in from the terminal: the line falls, the port’s own copy of it follows a cycle later, the bits shift in at the middle of each, and at the stop bit the byte lands in the port’s buffer, whose count rises and with it the interrupt line. You can see the next start bit already at the right edge; the terminal types ahead, and the buffer holds the bytes until the program reads them:

A byte in on the serial
    port: the line, its register, the bits left, the byte shifting in,
    the buffer's count and the interrupt
A byte in: the line, its register, the bits left, the byte shifting in, and the buffer's count and the interrupt as it lands.

And the bus underneath, during one status poll and the write that follows it: the core’s request goes out and is taken, the router hands it to the port, the port’s answer comes back through the router, the core waits in writeback until it does, and the load retires; then the store goes out the same way:

The bus under a status poll and
    a byte write: request and response valid and ready, the router's
    channels, the core's wait and the retire
The bus under a status poll and a byte write: request out, through the router to the port, the response back, the core waiting in writeback, and the retire.

Every one of these is drawn by the build from the run’s trace; none is drawn by hand.

Everything in the paper, every listing, every waveform and every number, comes out of the build from the same tree, so nothing in it can drift from the code.

A description language, not a synthesis language

One distinction matters before the comparisons, because the projects nearest to TxHDL sit on both sides of it. A hardware description language, an HDL, is for a designer who wants to state a circuit: which units there are, how they are connected, and what each does on each clock edge. A high-level synthesis language, an HLS, is for a designer who has an algorithm written as a sequential program and wants hardware that computes the same thing; the tool chooses the registers, the schedule and the interconnect. Both have a place in the toolbox of a hardware designer today, and their aims differ. With an HDL you decide the structure and take responsibility for it; with HLS you hand the structure to the tool and get back what its heuristics produce.

TxHDL is an HDL. A unit in it is the circuit you meant: its registers are the fields you declared, the logic between them is what you wrote in the loop body, and nothing is inferred beyond that. Rust supplies the language; it does not supply a compiler that turns your for loop into a pipeline.

Prior art, and how TxHDL differs from its closest cousins

We are not the first to embed a hardware language in a general-purpose one.

I think, however, we may be the first to hit a few sweet spots at the same time for TxHDL:

  • It’s a language embedding.
  • It’s an embedding into a language people actually use on the regular. (Sorry, Haskelites, sorry Scalalites!)
  • It doesn’t require you to build an industry around it to support it.
  • It actually works in hardware.
  • It’s an end-to-end build with modern tools, and checks which guard its behavior long term.

Here is how TxHDL relates to its close cousins. Please keep the HDL and HLS distinction in view, however.

Chisel embeds hardware description in Scala. A Chisel design is a Scala program that builds a circuit graph while it runs, so the Scala runtime and the JVM are part of the flow. With TxHDL there is no runtime beyond rustc and the library; the simulation is the Rust program itself.

RHDL is the same idea as TxHDL: a hardware language in Rust, with the clock domain as a type parameter. As far as we can tell, it is no longer maintained. We cover the same ground and add the three checks that close the loop: the trace, the netlist replayed against it, and the board.

XLS, from Google, is on the HLS side: a toolkit that takes a function written in its language, DSLX, and produces a pipeline for it. DSLX looks like Rust but is not Rust. It has a parser and a compiler of its own, and you cannot compile a DSLX design with rustc. You can compile a TxHDL design with nothing else, and you get the circuit you described, not a schedule a tool derived.

Bluespec1 sits between the two sides, at a higher level than an HDL but below HLS. You write guarded atomic rules, and the Bluespec compiler schedules them, which means a particular architecture, a rule scheduler around the state, comes with every design. TxHDL fixes nothing above the clock edge: a unit is a loop that waits, reads and drives, and what you do with that is up to you.

Where to find TxHDL

To check out what it’s about, read the 5-page paper here. You can then forget about it if it doesn’t catch your attention, that’s totally OK.

If you desire to dig deeper, however, you have some real work ahead of you. There are nine documents in all. The paper is just one among them, and we deliberately made it short, so people don’t get scared away.

All the existing documentation, excluding the code itself is bound in one PDF. This is 300+ pages with a bookmark per document at the time of this writing.

Each doc is also available on its own at the releases page.

We do the work on the hdlfactory forge, which is by invitation. A read-only mirror of the mainline, with the runtime, the examples, the core, every document the build produces and the releases, is public at github.com/filmil/hdl-txhdl. If you want to discuss, the paper lists the authors and how to reach us.

Full disclosure: we used a large language model, Claude, as an assistant in exploring the concepts and in writing the documents and the programs. This effectively gave us probably 220 fingers, instead of just 20, and I think we put them to good use.


  1. I heard about Bluespec a while back, bug got in contact recently with Bluespec due to an unrelated set of events. While this work is addressing some of the same questions that Bluespec does, this work was, and still is, completely independent of that realization. ↩︎