Marlin Handbook 🐟
Marlin is a hardware testing framework that just works. It comes as a normal Rust crate, so you don't need build scripts or preprocessing commands. That means:
- Unlike Verilator harnesses, then, you don't need to run a command to generate header and C++ files that you then connect to/run via a Makefile, Ninja, or other build system.
- Unlike cocotb tests, you don't have to use Makefiles or write Python driver code, use fancy decorators on the tests, and lose LSP information for hardware model ports.
Moreover, Marlin core is thread-safe, meaning you can use cargo test/#[test]
for your tests! It integrates perfectly and completely noninvasively into the
existing Rust ecosystem.
Features
Marlin comes with prebuilt integration for (System)Verilog and Spade (with experimental Veryl support), and offers:
- A declarative API for writing tests in plain Rust, treating the hardware
models as normal
structs with fields and member functions (learn more). - A safe procedural API for dynamically constructing bindings to Verilog and interacting with opaque hardware models at runtime (learn more).
- A library for any hardware description language (HDL) that compiles to Verilog to get all of the above.
You can also call Rust functions from Verilog.
Future Work
Planned features include:
- Ports wider than 64 bits (#7)
- Static linking + build scripts as an option (#23)
- Supporting the Calyx intermediate language (#8)
- Supporting the Veryl HDL (#8)
Getting Started
Install Rust
First, make sure you've installed a Rust toolchain, which
should install cargo for you.
Install Verilator
The current integrations for Verilog and Spade use a Verilator backend, which you need to install. For example, on macOS:
brew install verilator
and on Ubuntu:
apt-get install verilator
Check this list of packages to find one for your operating system and view the official installation instructions if you need more help.
You're done! Now, it's time to get started testing some Verilog.
Verilog Quickstart
note
This tutorial is aimed at Unix-like systems like macOS, Linux, and WSL.
In this tutorial, we'll setup a SystemVerilog project and test our code with
Marlin. You can find the full source code for this tutorial here (see in particular the simple_test.rs file).
We won't touch on the advanced aspects or features; the goal is just to provide a simple overfiew sufficient to get started.
Part 1: The Basics
Let's call our project "tutorial-project" (you are free to call it however you like):
mkdir tutorial-project
cd tutorial-project
git init # optional, if using git
Here's what our project will look like in the end:
.
├── Cargo.toml
├── Cargo.lock
├── .gitignore
├── src
│ ├── lib.rs
│ ├── main.sv
└── tests
└── simple_test.rs
We'll write a very simple SystemVerilog module: one that forwards its inputs to its outputs.
mkdir src
vi src/main.sv
I'm using the vi editor here, but you can use whichever editor you prefer.
For our forwarding module, we'll just pass a medium-sized input to a corresponding output:
// file: src/main.sv
module main(
input[31:0] medium_input,
output[31:0] medium_output
);
assign medium_output = medium_input;
endmodule
Part 2: Testing
Now that we have the setup out of the way, we can start testing our code from Rust. We'll initialize a Rust project:
cargo init --lib
In the Cargo.toml generated, we'll want to add some dependencies:
cargo add marlin --features verilog
cargo add snafu --dev
The only required crate is marlin, but I strongly recommend at this stage of
development to use snafu, which will display a human-readable error trace upon
Result::Err.
caution
Please use snafu! 😂
In the lib.rs, we'll create the binding to our Verilog module:
#![allow(unused)] fn main() { // file: src/lib.rs use marlin::verilog::prelude::*; #[verilog(src = "src/main.sv", name = "main")] pub struct Main; }
This tells Marlin that the struct Main should be linked to the main module
in our Verilog file.
help
It's not necessary to even use the lib.rs -- you can put marlin in your
[dev-dependencies] section in Cargo.toml and construct the bindings directly
in your test files.
Finally, we'll want to actually write the code that drives our project in simple_test.rs:
mkdir tests
vi tests/simple_test.rs
#![allow(unused)] fn main() { // file: tests/simple_test.rs use tutorial_project::Main; use marlin::verilator::{VerilatorRuntime, VerilatorRuntimeOptions}; use snafu::Whatever; #[test] //#[snafu::report] fn forwards_u32max_correctly() -> Result<(), Whatever> { let runtime = VerilatorRuntime::new( "build".into(), &["src/main.sv".as_ref()], &[], [], VerilatorRuntimeOptions::default(), )?; let mut main = runtime.create_model_simple::<Main>()?; main.medium_input = u32::MAX; println!("{}", main.medium_output); assert_eq!(main.medium_output, 0); main.eval(); println!("{}", main.medium_output); assert_eq!(main.medium_output, u32::MAX); Ok(()) } }
caution
Using #[snafu::report] on the function gives error messages that are
actually useful, but sometimes breaks LSP services like code completion.
I recommend to only apply it to your test functions when you actually
encounter an error.
Let's break down the relevant parts of what's going on here.
We first setup the Verilator runtime configuration. We'll use a build directory called "build" in the local directory.
#![allow(unused)] fn main() { let runtime = VerilatorRuntime::new( "build".into(), // build directory (relative) &["src/main.sv".as_ref()], // source files &[], // include search paths [], // DPI functions VerilatorRuntimeOptions::default_logging(), // configuration )?; }
tip
Add this build directory to your .gitignore file if you're using git.
You can fill in the source files (2nd argument) by, for example, finding all .v files in a
source direcory with std::fs::read_dir. Since we only have one, we've
hardcoded it.
Then, we instantiate the model:
#![allow(unused)] fn main() { let mut main = runtime.create_model::<Main>()?; }
I won't comment on the rest; it's just regular Rust --- including the part where
we assign to values and call eval() on the model object! (Yes, that is the
same as Verilator's evaluation method).
Finally, we can simply use cargo test to drive our design!
Dynamic Bindings to Verilog
note
This tutorial is aimed at Unix-like systems like macOS, Linux, and WSL.
In this tutorial, we'll setup a SystemVerilog project and test our code with
Marlin. You can find the full source code for this tutorial here (see in particular the dynamic_model_tutorial.rs file).
I'll be assuming you've read the tutorial on testing Verilog projects; if not, read that first and come back. In particular, I won't be reexplaining things I discussed in that tutorial, although I will still walk through the entire setup.
Part 1: Setup
Let's call our project "tutorial-project" (you are free to call it however you like):
mkdir tutorial-project
cd tutorial-project
git init # optional, if using git
Here's what our project will look like in the end:
.
├── Cargo.toml
├── Cargo.lock
├── .gitignore
├── src
│ ├── lib.rs
│ ├── main.sv
└── tests
└── dynamic_test.rs
Let's use the same SystemVerilog module from the Verilog quickstart.
mkdir src
vi src/main.sv
// file: src/main.sv
module main(
input[31:0] medium_input,
output[31:0] medium_output
);
assign medium_output = medium_input;
endmodule
Part 2: Testing
We'll create a new Rust project:
cargo init --lib
Next, we'll add Marlin and other desired dependencies.
cargo add marlin --dev # no features sneeded
cargo add snafu --dev
caution
Please use snafu! 😂
We will illustrate using dynamic models by implementing the exact same test as we did in the Verilog quickstart.
The code for dynamic models is slightly more verbose. It's not necessarily meant for human usage, though; this API is better suited for using Marlin as a library (e.g., writing an interpreter).
mkdir tests
vi tests/dynamic_test.rs
// file: tests/simple_test.rs use marlin::verilator::{ AsDynamicVerilatedModel, PortDirection, VerilatedModelConfig, VerilatorRuntime, VerilatorRuntimeOptions, }; use snafu::Whatever; //#[snafu::report] fn main() -> Result<(), Whatever> { let runtime = VerilatorRuntime::new( "build2".into(), &["src/main.sv".as_ref()], &[], [], VerilatorRuntimeOptions::default(), )?; let mut main = runtime.create_dyn_model( "main", "src/main.sv", &[ ("medium_input", 31, 0, PortDirection::Input), ("medium_output", 31, 0, PortDirection::Output), ], VerilatedModelConfig::default(), )?; main.pin("medium_input", u32::MAX).whatever_context("pin")?; println!("{}", main.read("medium_output").whatever_context("read")?); assert_eq!( main.read("medium_output").whatever_context("read")?, 0u32.into() ); main.eval(); println!("{}", main.read("medium_output").whatever_context("read")?); assert_eq!( main.read("medium_output").whatever_context("read")?, u32::MAX.into() ); Ok(()) }
caution
Using #[snafu::report] on the function gives error messages that are
actually useful, but sometimes breaks LSP services like code completion.
I recommend to only apply it to your test functions when you actually
encounter an error.
We can cargo test as usual to test.
If you're using git, remember to add build2/ to your .gitignore.
Make sure you pass in the correct filename to create_dyn_model.
You only need to pass in a correct subset of the ports.
You can use create_dyn_model again with different ports. Of course, if you use
the same ports, the model will just be loaded from the cache.
You need to bring the AsDynamicVerilatedModel trait into scope to use any methods on a dynamic model.
Calling Rust from Verilog
note
This tutorial is aimed at Unix-like systems like macOS, Linux, and WSL.
In this tutorial, we'll setup a SystemVerilog project and test our code with
Marlin. You can find the full source code for this tutorial here (see in particular the dpi_tutorial.rs file).
I'll be assuming you've read the tutorial on testing Verilog projects; if not, read that first and come back. In particular, I won't be reexplaining things I discussed in that tutorial, although I will still walk through the entire setup.
Part 1: Setup
Let's call our project "tutorial-project" (you are free to call it however you like):
mkdir tutorial-project
cd tutorial-project
git init # optional, if using git
Here's what our project will look like in the end:
.
├── Cargo.toml
├── Cargo.lock
├── .gitignore
├── src
│ ├── lib.rs
│ ├── dpi.sv
└── tests
└── dpi_test.rs
We'll have a simple SystemVerilog module that writes the result of three, a DPI function with a single integer output.
mkdir src
vi src/dpi.sv
// file: src/dpi.sv
import "DPI-C" function void three(output int out);
module main(output logic[31:0] out);
int a = 0;
initial begin
three(a);
$display("%d", a);
out = a;
end
endmodule
Part 2: Testing
We'll create a new Rust project:
cargo init --lib
Next, we'll add Marlin and other desired dependencies.
cargo add marlin --features verilog --dev
cargo add snafu --dev
caution
Please use snafu! 😂
Finally, we need the Rust file where we define the DPI function and drive the model.
mkdir tests
vi tests/dpi_test.rs
// file: tests/dpi_test.rs use snafu::Whatever; use marlin::{ verilator::{VerilatorRuntime, VerilatorRuntimeOptions}, verilog::prelude::*, }; #[verilog::dpi] pub extern "C" fn three(out: &mut u32) { *out = 3; } #[verilog(src = "src/dpi.sv", name = "main")] struct Main; //#[snafu::report] fn main() -> Result<(), Whatever> { let runtime = VerilatorRuntime::new( "artifacts".into(), &["src/dpi.sv".as_ref()], &[], [three], VerilatorRuntimeOptions::default(), )?; let mut main = runtime.create_model_simple::<Main>()?; main.eval(); assert_eq!(main.out, 3); Ok(()) }
caution
Using #[snafu::report] on the function gives error messages that are
actually useful, but sometimes breaks LSP services like code completion.
I recommend to only apply it to your test functions when you actually
encounter an error.
We can cargo test as usual to test.
The magic happens here:
#![allow(unused)] fn main() { #[verilog::dpi] pub extern fn three(out: &mut u32) { *out = 3; } }
By applying #[verilog::dpi], we turn our normal Rust function into a DPI one.
We need to apply pub and extern (or extern "C") so that Rust
exposes the function correctly to C.
DPI functions cannot have a return value and only take primitive integers (for input) or mutable references to primitive integers (for output/inout) as
arguments. Beside that, there are no restrictions on the content --- write
whatever Rust code you want!
Then, we told the runtime about this function:
let runtime = VerilatorRuntime::new(
"artifacts".into(),
&["src/dpi.sv".as_ref()],
&[],
- [],
+ [three],
VerilatorRuntimeOptions::default(),
true,
)?;
See the documentation for the #[verilog::dpi] macro for more details.
Wide Ports
Marlin supports wide (larger than 64 bits) ports in both static and dynamic bindings. The Verilator interface is exposed through a safe Rust wrapper in either case.
There is currently some overhead in using wide ports because the Rust side is entirely (and safely) isolated from the C++.
This overhead involves a memcpy of the entire value when reading or writing and, if using the dynamic API, an additional allocation (from Box::from).
Static
If a wide value is declared in Verilog with [MSB:LSB], then LENGTH will be compute_wdata_word_count_from_width_not_msb(MSB + 1 - LSB).
pub struct WideIn<const LENGTH: usize> {
/* private members */
}
pub struct WideOut<const LENGTH: usize> {
/* private members */
}
Dynamic
pub enum VerilatorValue<'a> {
...,
WDataInP(&'a [types::WData]),
WDataOutP(Box<types::WData>),
}
Spade Quickstart
note
This tutorial is aimed at Unix-like systems like macOS, Linux, and WSL.
In this tutorial, we'll setup a Spade project and test our code with Marlin. You can find the full source code for this tutorial here.
I'll be assuming you've read the tutorial on testing Verilog projects; if not, read that first and come back.
Also, make sure you have a Spade toolchain installed, although we'll only be using the Swim build tool (follow the instructions here to install it).
note
If you already have a Swim project and are looking to integrate Marlin into it, you don't need to read Part 1 too carefully.
Part 1: Making a Swim Project
Let's call our project "tutorial-project" (you are free to call it however you like):
swim init tutorial-project
cd tutorial-project
git init # optional, if using git
Here's what our project will look like in the end:
.
├── swim.toml
├── swim.lock
├── Cargo.toml
├── src
│ ├── lib.rs
│ └── main.spade
└── tests
└── simple_test.rs
In main.spade (which should already exist after running swim init), we'll write some simple Spade code:
// file: src/main.spade
#[no_mangle(all)]
entity main(out: inv &int<8>) {
set out = &42;
}
You can read the Spade book for an
introduction to Spade; this tutorial will not focus on teaching the language.
Nonetheless, the essence of the above code is to expose an inverted wire which
we pin to the value 42 (think of assigning to an output in Verilog).
We'll write a very simple SystemVerilog module: one that forwards its inputs to
its outputs.
Part 2: Setting up Marlin
cargo init --lib
cargo add marlin --features spade --dev
cargo add snafu --dev
The only required crate is marlin, but I strongly recommend at this stage of
development to use snafu, which will display a human-readable error trace upon
Result::Err.
caution
Please use snafu! 😂
In the test file, we'll create the binding to our Spade module:
mkdir tests
vi tests/simple_test.rs
#![allow(unused)] fn main() { // file: tests/simple_test.rs use marlin::spade::prelude::*; #[spade(src = "src/main.spade", name = "main")] pub struct Main; }
This tells Marlin that the struct Main should be linked to the main entity
in our Spade file. You can instead put this in your lib.rs file if you prefer.
Finally, we'll want to actually write the code that drives our hardware:
// file: tests/simple_test.rs use marlin::spade::prelude::*; use snafu::Whatever; #[test] //#[snafu::report] fn main() -> Result<(), Whatever> { let runtime = SpadeRuntime::new(SpadeRuntimeOptions { call_swim_build: true, /* warning: not thread safe! don't use if you * have multiple tests */ ..Default::default() })?; let mut main = runtime.create_model_simple::<Main>()?; main.eval(); println!("{}", main.out); assert_eq!(main.out, 42); // hardcoded into Spade source Ok(()) }
caution
Using #[snafu::report] on the function gives error messages that are
actually useful, but sometimes breaks LSP services like code completion.
I recommend to only apply it to your test functions when you actually
encounter an error.
Finally, we can simply use cargo test to drive our design! It will take a while before it starts doing Marlin dynamic compilation because it needs to first build the Spade project by invoking the Spade compiler.
warning
By default, SpadeRuntime does not swim build because it is not thread-safe. If
you have multiple tests, run swim build beforehand and then run cargo test
if you have disabled the automatic swim build via
SpadeRuntimeOptions::call_swim_build.
Note that, unlike the Verilog project tutorial, you don't need to add another
directory to your .gitignore, if you have one, because the SpadeRuntime
reuses the existing build/ directory managed by Swim. Thus, you should add
that to your .gitignore instead. swim init should do that automatically,
though.
Veryl Quickstart
note
This tutorial is aimed at Unix-like systems like macOS, Linux, and WSL.
caution
Veryl support is still experimental.
In this tutorial, we'll setup a Veryl project and test our code with Marlin. You can find the full source code for this tutorial here.
I'll be assuming you've read the tutorial on testing Verilog projects; if not, read that first and come back.
Also, make sure you have a Veryl toolchain installed.
note
If you already have a Veryl project and are looking to integrate Marlin into it, you don't need to read Part 1 too carefully.
Part 1: Making a Veryl Project
Let's call our project "tutorial_project" (you are free to call it however you like):
veryl new tutorial_project
cd tutorial-project
git init # optional, if using git
Here's what our project will look like in the end:
├── Veryl.toml
├── Veryl.lock
├── Cargo.toml
├── Cargo.lock
├── src
│ ├── lib.rs
│ └── main.veryl
└── tests
└── simple_test.rs
In main.veryl, we'll write some simple Veryl code:
mkdir src
vi src/main.veryl
// file: src/main.veryl
module Wire(
medium_input: input logic<32>,
medium_output: output logic<32>
) {
assign medium_output = medium_input;
}
You can read the Veryl book for an introduction to Veryl; this tutorial will not focus on teaching the language. If you know Verilog, the code should feel very familiar.
Part 2: Setting up Marlin
cargo init --lib
cargo add marlin --features veryl --dev
cargo add snafu --dev
The only required crate is marlin, but I strongly recommend at this stage of
development to use snafu, which will display a human-readable error trace upon
Result::Err.
caution
Please use snafu! 😂
In the test file, we'll create the binding to our Veryl module:
mkdir tests
vi tests/simple_test.rs
#![allow(unused)] fn main() { // file: tests/simple_test.rs use marlin::veryl::prelude::*; #[veryl(src = "src/main.veryl", name = "Wire")] pub struct Wire; }
This tells Marlin that the struct Wire should be linked to the Wire module
in our Veryl file. You can instead put this in your lib.rs file if you prefer.
Finally, we'll want to actually write the code that drives our hardware in simple_test.rs:
#![allow(unused)] fn main() { // file: tests/simple_test.rs use marlin::veryl::prelude::*; use snafu::Whatever; #[veryl(src = "src/main.veryl", name = "Wire")] pub struct Wire; #[test] //#[snafu::report] fn forwards_correctly() -> Result<(), Whatever> { let runtime = VerylRuntime::new(VerylRuntimeOptions { call_veryl_build: true, /* warning: not thread safe! don't use if you * have multiple tests */ ..Default::default() })?; let mut main = runtime.create_model::<Wire>()?; main.medium_input = u32::MAX; println!("{}", main.medium_output); assert_eq!(main.medium_output, 0); main.eval(); println!("{}", main.medium_output); assert_eq!(main.medium_output, u32::MAX); Ok(()) } }
caution
Using #[snafu::report] on the function gives error messages that are
actually useful, but sometimes breaks LSP services like code completion.
I recommend to only apply it to your test functions when you actually
encounter an error.
Finally, we can simply use cargo test to drive our design! It will take a while before it starts doing Marlin dynamic compilation because it needs to first build the Veryl project by invoking the Veryl compiler.
Bridging Macros
Marlin uses "bridging" macros to define bindings between Rust structs and hardware modules:
Under the Verilator backend, these take a common interface:
name = "<name>": The name of the module.src = "<file>": The file where the module is defined relative to the manifest directory.
See the relevant internal documentation for technical explanation.
Waveform Tracing
Overview
(Bad) example here.
You can open a VCD for a Verilated model using the .open_vcd function, which takes in anything that can turn into a Path.
The .dump and other functions are bridged directly to the Verilator functions and, as such, will behave as you expect (but through a safe Rust API).
The VCD is automatically closed and deallocated when out of scope. Lifetimes enforce that you cannot use the VCD past the scope of the runtime whence the model you created the VCD came.
Until https://github.com/verilator/verilator/issues/5813 gets fixed, .open_vcd will panic if you call it more than once.
You can consult the reference documentation for VCDs here.
Tips
You might find yourself wanting to write a function on your model (let's say you declared it as struct Top) to simulate a clock cycle.
You will need to remember to update the VCD.
For instance:
impl Top<'_> {
fn tick(&mut self, vcd: &mut Vcd<'_>, timestamp: &mut u64) {
self.clk = 0;
self.eval();
*timestamp += 1;
vcd.dump(*timestamp);
self.clk = 1;
self.eval();
*timestamp += 1;
vcd.dump(*timestamp);
}
}
You could also wrap the Vcd in another struct:
pub struct GoodVcd<'a> {
inner: Vcd<'a>,
timestamp: u64,
}
impl<'a> From<Vcd<'a>> for GoodVcd<'a> {
fn from(vcd: Vcd<'a>) -> Self {
Self {
inner: vcd,
timestamp: 0,
}
}
}
Model Traits
There are two main traits for Verilated models:
-
AsVerilatedModel:This trait is implemented for types derived using
#[verilog]or#[spade], etc. It should not be manually derived. You can use the type-level functions provided by this trait to get information about the model. -
AsDynamicVerilatedModel:This trait is implemented for all models, derived and dynamic. It provides a safe runtime API for accessing ports by strings (instead of using the actual
structfields). For derived models, you typically won't need to use it because you'll just be able to set and read fields directly.
Dynamic API Values
VerilatorValue is a dynamic version of a Verilator value: an enum representing all possible marlin_verilator::types.
A lot of the values implement From for an appropriate type.
For instance, a VerilatorValue::WDataInP can be created by into()ing on a &[types::WData; LENGTH].
This often lets you write tests with assert_eq! without needing to use the enum at all, just an .into() on one argument.
However, you should be careful: if you want to pin with the Into, you need to use &[] because [] will be interpreted as an output value.
Consult the documentation for all the implementations and details.
About Internal Documentation
This is a work-in-progress effort to explain how Marlin works in a technical level.
Name
Marlin are fish that can swim pretty fast1.
https://pmc.ncbi.nlm.nih.gov/articles/PMC5087677/
Release Process
- After a
feat,feat!,fix, orfix!, merge the release-please PR after running/bin/sh prepare-release.shfrom a named branch at project root. - After any further fixes are required and commited, switch to and
git pullthe main branch and runcargo release --workspace --executefrom the project root.
Multiple semantic changes in one release
After a feat, feat!, fix, or fix!, release-please will create a PR for
a new version following semver. A subsequent semantic change can explicit set
the version release field in release-please-config.json and remove it on the
CI-trigger commit to the release-please PR. However, multiple fixes in a row
will be considered part of the same minor patch bump
Excluding crates
Any crate to be excluded from publishing should contain the following section in
its Cargo.toml:
[package.metadata.release]
release = false
publish = false
How Marlin Works
Marlin uses procedural macros to generate a Rust wrapper over dynamically-linked symbols generated by a hardware simulator (in this case, verilator).
Each "bridging" macro calls the helper function build_verilated_struct defined in the marlin-verilog-macro-builder crate.
See also the formal interface in MacroArgs.
Verilator Runtime
- All models are "owned" by the runtime. Lifetimes enforce that they cannot outlive it. All deallocation of Verilated models is done when the runtime is dropped --- when an individual model is dropped, no deallocation occurs. This allows, for instance, for constructing a model with a struct initializer, where
Alu { ..alu }would otherwise have deallocated the model when dropping the oldaluand caused a double-free error when the newly-constructed model was also dropped.
Verilator Port Generation
This page details how Verilator translates Verilog interfaces into C++.
A gist with the relevant Verilator header file code.
Explanation
The length is computed (via "most significant bit index" - "least significant bit index + 1").
If this length is <= 64, the appropriate primitive integer type is used.
Otherwise, a VlWide is used, which is an array of length ceil(length / (sizeof(WData) * 8)), i.e., the minimum number of WDatas needed to represent the value.
Example
The following signature
input single_input,
input[7:0] small_input,
input[63:0] medium_input,
input[127:0] big_input,
input[256:128] big2_input,
input[127:126] weird_input,
output single_output,
output[7:0] small_output,
output[63:0] medium_output,
output[127:0] big_output,
output[256:128] big2_output,
output[127:126] weird_output
generates this code (on Verilator 5.042 2025-11-02 rev UNKNOWN.REV)
// PORTS
// The application code writes and reads these signals to
// propagate new values into/out from the Verilated model.
VL_IN8(&single_input,0,0);
VL_IN8(&small_input,7,0);
VL_IN8(&weird_input,127,126);
VL_OUT8(&single_output,0,0);
VL_OUT8(&small_output,7,0);
VL_OUT8(&weird_output,127,126);
VL_INW(&big_input,127,0,4);
VL_INW(&big2_input,256,128,5);
VL_OUTW(&big_output,127,0,4);
VL_OUTW(&big2_output,256,128,5);
VL_IN64(&medium_input,63,0);
VL_OUT64(&medium_output,63,0);
among the public class members of the design.
The additional macro parameters to VL_INW/VL_OUTW are the number of "words" in (i.e., the length of) the VlWide array
In Marlin
Wide values are read and written over the FFI interface via pointers to avoid ABI issues with passing around large arrays (VlWide).
All other values are copied.
See build_library.rs in the verilator crate for details.
The functions compute_wdata_word_count_from_width_not_msb and compute_approx_width_from_wdata_word_count help capture the wide port translation.