Marlin Handbook 🐟

CI Badge Code Style Badge cargo-deny badge Crates.io Version docs.rs Crates.io License

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:

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 struct fields). 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.

1

https://pmc.ncbi.nlm.nih.gov/articles/PMC5087677/

Release Process

  1. After a feat, feat!, fix, or fix!, merge the release-please PR after running /bin/sh prepare-release.sh from a named branch at project root.
  2. After any further fixes are required and commited, switch to and git pull the main branch and run cargo release --workspace --execute from 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 old alu and 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.