Elixir HDL — a hardware description language embedded in Elixir. You write synchronous digital hardware as ordinary Elixir modules; EHDL elaborates them into an intermediate representation, emits synthesizable Verilog, and runs the same designs in a fast native simulator so you can test them with ExUnit.
EHDL is a research/hobby toolchain built around a small, honest core: two
behavioral primitives (comb and on) that map directly to how real silicon
works, plus a component/interface system for composing them into larger designs.
The reference design is a USB 2.0 Full-Speed → ESP32 serial bridge (CDC-ACM)
targeting the Lattice ECP5 on a ULX3S board.
Status: pre-release, under active development. The DSL, elaborator, Verilog backend, and simulator are working; the USB stack enumerates in simulation. APIs may still change.
Verilog and VHDL make you carry the whole language in your head to describe what is, at bottom, a small set of ideas: registers that update on a clock edge, combinational logic that settles continuously, and structural composition of the two. EHDL takes those ideas as its primitives and borrows Elixir's macro system for everything else — so a component is a plain module, parameterization is just compile-time values, replication is a comprehension, and your testbench is ExUnit. The payoff is that the same source is analyzable (a linter with real hardware rules), simulatable (native-speed, scriptable from IEx), and synthesizable (Verilog out).
Two primitives carry the whole language, and they mean different things by =:
-
comb do … end— combinational logic. A standing waterfall: every assignment describes a wire that is continuously equal to its right-hand side, all the time, with no clock involved.=means "is permanently wired to." Order is (almost) irrelevant; feeding acombresult back into its own input with no register in the loop is a real bug (and EHDL's analyzer flags it). -
on :clk do … end— clocked (sequential) logic. On each rising edge, every right-hand side is read from the old state simultaneously, then every left-hand side is written simultaneously.=means "on the next edge, becomes." This is the only primitive that creates flip-flops. A register that isn't written on an edge simply holds — and "holding" is itself an active decision the logic makes every cycle.
Everything else — finite state machines, reusable logic templates, replicated
lanes — is sugar that lowers into those two. A wire is neutral: whether it
becomes a flip-flop or a bare wire is decided by whether an on block drives
it, not by its declaration.
defmodule Counter do
use Hw.Component
clock :clk
input :rst, 1
input :en, 1
output :count, 8
on :clk do
if rst do
count = 0
else
if en do
count = count + 1
end
end
end
end
# Elaborate to IR and emit Verilog
design = Hw.Compile.Elaborate.elaborate(Counter)
verilog = Hw.emit(design)A slightly richer component — an 8N1 UART transmitter — shows the FSM sugar,
combinational outputs, and reusable logic fragments (defhw) working together:
defmodule Hw.UART.TX do
use Hw.Component
param :CLK_FREQ, default: 48_000_000
param :BAUD_RATE, default: 115_200
clock :clk, freq: 48.0
input :rst, 1
input :data, 8
input :valid, 1
output :ready, 1
output :txd, 1
wire :shift_reg, 10, init: 0b1111111111
wire :tick, 1
comb do
tick = (baud_cnt == CLK_FREQ / BAUD_RATE - 1)
txd = shift_reg[0..0] # txd IS the LSB of the shift register
end
fsm :tx_state, clock: :clk, reset: :rst, init: :idle do
defaults do
ready = 1
end
case tx_state do
:idle ->
on valid do
load_frame(data)
next :sending
end
:sending ->
ready = 0
on tick do
shift_bit()
on bit_cnt == 9, next: :idle
end
end
end
endDeclarations (the nouns — inert structure):
param, clock (with domain + reset_style: :sync | :async | :none),
input / output / inout, wire, memory, complex, blackbox,
tristate, and interface-role bindings provides / consumes.
Logic blocks (the verbs — behavior):
comb and on (the two atoms); fsm (state machines, :binary / :onehot
/ :gray encodings); defhw (inlinable logic templates — expression-level or
statement-level, or simulation-only when they contain on blocks); generate
(compile-time replication over a range); hdl_case (a case variant that
accepts <<signal::width>> binary patterns); and structural composition via
instance, interface, and connect.
Under lib/hw/std/:
- Clocking / reset:
ResetSync(two-flop synchronizer with hold counter),CDC.Sync2,CDC.PulseSync. - Interfaces / buffers:
FIFO,AXI4Master,AXI4Slave,LVDS25. - Serial:
UART.TX,UART.RX,ESP32ProgCtrl. - USB Full-Speed stack:
USB.FSPhy(NRZI, bit-stuffing, EOP),USB.SIE(packet FSM, PID decode, CRC5/CRC16, endpoint buffers),USB.CDCSerial(enumeration, descriptor ROM, EP0 control, EP1 bulk bridge), plusUSB.CRC5,USB.CRC16,USB.ClockTrim. - DSP:
CORDIC.
The reference top-level design lives at designs/hello_board/top.ex
(HelloBoard.Top) and wires the USB stack to a UART/ESP32 bridge for the ULX3S.
Requires Elixir 1.20+ / Erlang 29+ and a Rust toolchain (the simulator core is a Rustler NIF, built automatically on first compile).
git clone https://github.com/HeroesLament/ehdl.git
cd ehdl
mix deps.get
mix compile # also builds the native simulator NIF
mix testSimulate and test. Designs run in a native simulator driven from Elixir, so
testbenches are ordinary ExUnit tests — drive inputs, tick the clock, assert on
signals. See test/ for the HelloBoard UART, PHY, CDC, and loopback benches,
and the trace/query helpers for waveform-style assertions.
Analyze. mix hw.check runs a hardware-aware linter over all compiled
components and exits non-zero on errors (CI-friendly):
mix hw.check
mix hw.check --modules Hw.USB.SIE,Hw.USB.CDCSerial
mix hw.check --warnings-as-errorsRules include combinational-loop detection, clock-domain-crossing checks,
multiple-driver / undriven-output / unconnected-input analysis, latch inference,
reset coverage, signal-width and endianness mismatches, and more (see
lib/hw/analysis/rules/).
Emit Verilog. Elaborate a component and call Hw.emit/1 to get
synthesizable Verilog for your toolchain (e.g. Yosys + nextpnr-ecp5 for the
ULX3S).
Diagram. mix hw.diagram renders a component's wiring as Typst/SVG/PDF,
Graphviz DOT, or Mermaid:
mix hw.diagram HelloBoard.Top --format svg --output diagram.svg
mix hw.diagram HelloBoard.Top --format mermaidSnapshot. mix hw.snapshot writes a filtered source tarball
(~/ehdl_snapshot_*.tar.gz), excluding build artifacts.
lib/hw/
dsl/ # the component DSL: declarations, logic blocks, parser
ir/ # intermediate representation: ops, types, signals
compile/ # elaboration (DSL → IR), validation
emit/ # Verilog backend
sim/ # native (Rustler) simulator, scheduler, testbench harness
std/ # standard component library (see above)
analysis/ # hardware-aware linter and its rules
trace/, simtrace/, waveform/ # tracing and waveform tooling
boards/ # board support (ULX3S: PLL, pin constraints)
native/hw_sim_nif/ # Rust simulation core
designs/ # concrete designs (hello_board)
docs/ # USB theory notes, hdl_case reference, design findings
The public module namespace is Hw.* (e.g. Hw.Component, Hw.USB.SIE); the
OTP application and package are named ehdl. "EHDL" is the project/brand name;
Hw remains the code namespace.
Dual-licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option. Unless you explicitly state otherwise, any contribution you submit for inclusion shall be dual-licensed as above, without additional terms.