Ditching C++ Testbenches: Building a Cycle-Accurate Verilator Framework in Pure Julia
Ditching C++ Testbenches: Building a Cycle-Accurate Verilator Framework in Pure Julia
If you design digital logic, you already know the open-source heavy hitter: Verilator. It compiles Verilog into incredibly fast C++ models. But if you have ever tried to actually test that compiled silicon, you know the pain that follows. Writing C++ testbenches to manage clock toggles, evaluate memory pointers, and feed mathematical test vectors into uint32_t arrays feels like doing data science with a hammer.
The industry standard workaround is cocotb, which uses Python to provide a beautiful, asynchronous testing environment. But Python has a speed limit, and bridging it to C++ adds overhead.
What if you could drive cycle-accurate Verilog using the asynchronous elegance of cocotb, but with the raw execution speed, LLVM-backed compilation, and native mathematical ecosystem of Julia?
Welcome to VeriJul. Here is how I built a custom Foreign Function Interface (FFI) bridge to control Verilator directly from Julia.
The Architecture: Bridging Julia and Silicon
The goal was simple in theory: let Verilator compile the Verilog into a shared C++ library (.so), dynamically link it into Julia's memory space, and interact with the virtual pins.
In practice, getting Julia’s compiler to play nicely with Verilator’s output required breaking a few rules.
Overcoming the Static ccall Barrier
Julia provides a native ccall function to execute C/C++ code, but it aggressively optimizes by expecting a hardcoded library path at compile time. Because VeriJul is a dynamic framework that generates .so files on the fly, this caused immediate precompilation failures.
The fix was bypassing ccall's pathing entirely and dropping down to Julia's built-in Dynamic Library module (Libdl). By manually opening the shared library, locating the exact memory addresses of the Verilator C++ lifecycle functions (top_create, top_eval), and passing raw pointers, the framework achieves zero-overhead execution without upsetting the LLVM JIT.
Taming Verilator 5.0+
Modern Verilator is heavily optimized, which introduced two massive hurdles:
The Multithreading Context: Even for a single-threaded 8-bit filter, Verilator 5.0+ aggressively links
VlThreadPool. Failing to compileverilated_threads.cppalongside the model results in fatal OS-level dynamic linker errors (undefined symbol: _ZN12VlThreadPool...).XML Parsing: Verilator silently changed its schema. Port directions shifted from
dir="in"todir="input", which instantly broke the auto-generation of the Julia setter functions.
Building a robust extraction script in Julia solved both, automatically scanning the build directories and mapping the ports dynamically.
The Magic: Asynchronous Hardware Clocks
Managing clock cycles manually (set_clk(1); eval(); set_clk(0); eval();) is tedious. The true power of VeriJul lies in Julia's native asynchronous coroutines.
I built a background task that acts as a hardware oscillator. It constantly toggles the clock, evaluates the silicon, and yields control back to the Julia scheduler. We then wrap this in a custom @tick macro.
The result? You can write testbenches that look exactly like hardware timing diagrams.
The Hero Test: A 4-Tap Moving Average Filter
To prove the framework, I built a 4-tap FIR filter in Verilog. Because the testbench is running in Julia, generating complex mathematical stimulus is trivial—no massive C++ data arrays required.
Visibility: GTKWave Integration
An EDA tool is useless if you can't see the wires. By injecting --trace into the background Verilator commands and exposing the verilated_vcd_c.h libraries, VeriJul natively dumps standard .vcd files on every clock tick.
Open GTKWave, and you can watch your Julia-generated sine wave physically propagate through the digital pipeline, delayed by exactly four clock cycles.
What's Next for VeriJul?
This prototype proves that bridging Julia's scientific computing ecosystem with open-source hardware compilers is not only possible but incredibly performant. The next steps involve expanding the auto-generator to map wide SystemVerilog buses (like [255:0]) into Julia BigInt or byte arrays, opening the door for simulating heavy cryptography and networking cores.
Want to dive into the code? Check out the open-source repository [Link Placeholder] and try pushing your own DSP algorithms through virtual silicon today.
This draft captures the technical depth of the debugging process while highlighting the elegant final product. Should we focus our next development effort on setting up the GitHub Actions CI/CD pipeline so you can easily open-source this repository, or would you prefer to tackle the BigInt parsing for wider data buses first?
Comments
Post a Comment