背景
在 Verilator 的常规集成方式里,C++ 侧驱动 DUT(Design Under Test)通常是这样写的:
top.eval();
top.clk = 1; top.eval();
top.clk = 0; top.eval();
// 手动判定 "task 到底跑完没有"
一旦 SV 侧的 task 里带上 while 循环 和 @(posedge clk),情况立刻变得复杂:C++ 侧必须自己跟踪状态、手动推进时钟、轮询标志位,而且 task 一多,代码会以 O(n) 的速度膨胀,维护成本急剧上升。
本文介绍的方案想解决的核心问题是:让上层使用者完全不需要懂 Verilator。对他而言,调用一个仿真 task 就像调用一个普通函数:传参、等待、拿返回值。阻塞的接口、简单的返回值、屏蔽所有仿真细节,这是本设计的第一原则。
设计原则:让使用者"看不见" Verilator
在任何协同仿真系统里,都有两类人:
本方案把这两类人的职责硬性切分:
-
• 框架开发者 负责把 Vtop藏在 Worker 线程里,处理所有时序细节; -
• 接口使用者 只需要写 int r = engine.task1().get();,就像调一个普通函数。
为什么要做成阻塞? 因为 异步语义对不懂 Verilator 的使用者来说毫无意义。他们不关心 co_await、不关心时钟周期、不关心 VlCoroutine 是怎么调度的——他们只想要 result。阻塞接口是最简单、最不容易出错的选择:调用 -> 返回。没有回调、没有状态机、没有额外的心智负担。
整体架构
主线程只做三件事:提交请求、拿到 future、get() 阻塞。它完全不接触Vtop,也完全不关心仿真时间是怎么推进的。
Worker 线程独占 Vtop 和 VerilatedContext,所有时钟推进、协程调度都在这里发生。这是整个设计的安全边界:Verilator 的 --timing 模式大量依赖线程本地状态,把 Vtop 的构造、eval、final 全部锁在一个线程内,从根上避免了跨线程访问 UB。
关键角色
1. SV 侧:带 while 和 @ 的 task
task task1();
i1 = 0;
while (i1 < 5) begin
@(posedge clk);
i1++;
count1++;
end
result1 = count1;
endtask
task 本身没有返回值——这是 SV 语言层面的限制。我们用模块内的变量 result1 承载结果,C++ 侧完成后读取它。
2. .vlt 控制文件:暴露 task 给 C++
`verilator_config
public -module "top" -task "task1"
public -module "top" -task "task2"
Verilator 会把它们编译成 Vtop_top 的成员方法,返回 VlCoroutine:
class Vtop_top final {
// ...
VlCoroutine task1();
VlCoroutine task2();
};
注意:task 挂在 Vtop_top 上,而不是 Vtop 上。访问路径是 top->top->task1()——第一个 top 是 Vtop 实例,第二个 top 是 Vtop 里指向 Vtop_top 的指针成员。这一点非常容易被忽略,也是上手时最常见的坑。
3. 宏:一个 task 一行代码
#define DEFINE_SIM_TASK(NAME, RESULT) \
std::future<int> NAME() { \
return submit_sv_task<&Vtop_transform: translateY(:NAME, \
&Vtop_top::__PVT__##RESULT>(); \
}
这里用的是 C++17 起支持的非类型模板参数:成员函数指针(&Vtop_transform: translateY(:task1)和成员变量指针(&Vtop_top::__PVT__result1)都可以作为编译期常量传入模板。
加新 task 时,C++ 侧只加一行:
DEFINE_SIM_TASK(task3, result3)
没有分支、没有重复代码、没有额外的状态机。
编译期到运行期的映射
从 SV 源码 到 C++ 接口,经过 .vlt 处理、Verilator 代码生成、宏展开四步。使用者只需要看到最后的 std::future<int>——前面的一切都是编译期魔法。
核心机制
submit_sv_task 的两段式
template <auto TaskFn, auto ResultMember>
std::future<int> submit_sv_task() {
auto promise = std::make_shared<std::promise<int>>();
auto fut = promise->get_future();
{
std::lock_guard lk(queue_mtx_);
tasks_.emplace([this, promise] {
active_coros_.push_back(
run_task_coro<TaskFn, ResultMember>(promise));
});
}
queue_cv_.notify_one();
return fut;
}
这段代码分两段执行:
-
• 主线程侧:只创建一个 promise/future,把闭包入队,立刻返回future<int>——不阻塞、不等结果。 -
• Worker 侧:队列里的闭包被取出时,才在 Worker 线程里启动协程。
std::packaged_task 也可以实现类似功能,但它更适用于"任务本身直接算出结果"的场景。这里需要"启动协程 -> 挂起 -> 时钟推进 -> 恢复 -> 写结果"这样一条更长的链路,用 promise 手动控制更直观。
协程体只有 co_await
template <auto TaskFn, auto ResultMember>
VlCoroutine run_task_coro(std::shared_ptr<std::promise<int>> promise) {
active_count_.fetch_add(1);
co_await (top_->top->*TaskFn)();
int result = static_cast<int>(top_->top->*ResultMember);
promise->set_value(result);
active_count_.fetch_sub(1);
co_return;
}
这个协程体只做三件事:启动 SV task 并 co_await、完成后读结果、set_value 通知主线程。它完全不推进时钟——时钟由 Worker 主循环统一处理:
while (!stop_.load()) {
// 1) 消费提交队列
drain_tasks();
// 2) 有活跃协程就推进时钟
if (active_count_.load() > 0) {
top_->clk = 1; top_->eval(); context_->timeInc(1);
top_->clk = 0; top_->eval(); context_->timeInc(1);
}
}
时钟推进与协程调度解耦,这是整个设计最重要的简化点。协程只关心"我要等什么",Worker 循环只关心"仿真时间怎么走",两者互不干扰。
执行时序
这张时序图清楚地显示了 主线程的"阻塞" 与 Worker 线程的"忙碌" 之间的关系:主线程在 f1.get() 处完全停止,等待 Worker 线程一路把 task1 跑完;Worker 一旦 set_value,主线程立刻被唤醒继续。对使用者而言,这就是一个普通的同步调用——只是耗时略长。
数据流
数据从 主线程 -> 队列 -> Worker -> Vtop -> promise -> future -> 主线程 走了一整圈。唯一跨线程的通道是 promise/future——这是 C++ 标准库提供的、最安全的跨线程通信原语之一,不需要手写锁、条件变量或原子标记。
为什么这么设计
1. 主线程接口看起来完全同步
int r1 = engine.task1().get(); // 就是阻塞调用
调用者不需要理解协程、也不需要感知线程,接口语义简单到极致。他写的代码和调用 std::sqrt() 没有本质区别——输入、等待、输出。
2. 使用者完全不需要懂 Verilator
这一点再强调一次:这是本设计的核心目标。
-
• 他不知道 --timing是什么; -
• 他不知道 VlCoroutine是什么; -
• 他不知道 @(posedge clk)对应 C++ 里的什么; -
• 他甚至不需要知道仿真跑在另一个线程。
他只需要知道:"我调 task1(),它返回一个 future,我 get() 就拿到 int。"
3. 仿真状态绑定到 Worker 线程
Verilator 的 --timing 模式大量依赖线程本地状态(协程调度器、VlTriggerScheduler)。把 Vtop 和 VerilatedContext 的构造、eval、final 全部放在 Worker 线程内,避免跨线程访问导致 未定义行为。
粗实线 代表合法路径,细虚线 代表违规路径。主线程唯一被允许的通信方式是右侧的 future。
4. task 数量与代码量解耦
使用者新增 task 的成本几乎为零——不需要读一行 Verilator 文档。
5. 与 std::future 生态天然兼容
std::future<int> f = engine.task1();
// 可以传给任何接收 std::future<int> 的函数
std::future 是 C++ 标准库的一等公民。这意味着本框架天然可以和现有异步代码互操作:你可以把它塞进任何等待 future 的地方,也可以在未来把它换成 std::shared_future、std::promise、甚至 C++20 的 std::coroutine handle。
线程与状态归属
每个资源都有明确的归属:Vtop、VerilatedContext、active_coros_ 只属于 Worker 线程;主线程只持有 future。这种清晰的划分让代码的线程安全性一眼可辨。
构建与运行
verilate(sim
SOURCES top.vlt top.sv
TOP_MODULE top
VERILATOR_ARGS --timing -Wall -Wno-fatal
)
target_sources(sim PRIVATE main.cpp)
target_link_libraries(sim PRIVATE Threads::Threads)
mkdir build && cd build
cmake ..
make -j
./sim
输出:
[main] main thread id = 140234567890176
===== Round 1 / 5 =====
[main] futures submitted, doing other work ...
[3] task1: i1=1 count1=1
...
[11] task1 done, result1=5
[13] task2: i2=1 count2=1
...
[17] task2 done, result2=3
[main] task1 returned: 5
[main] task2 returned: 3
使用者看到的输出里,只有[main] task1 returned: 5 这一行是他关心的;其余的 [3] task1: i1=... 都是 Verilator 在仿真内部 打印的调试信息。
小结
这套结构把 Verilator 的时序语义、C++ 的异步抽象、使用者的心智模型 三者分层:
-
• 协程 负责 任务生命周期; -
• Worker 线程 负责 仿真时间; -
• future负责 跨线程结果传递; -
• 使用者 只需要面对一个 阻塞的、返回 int的函数。
三者各司其职,新增 SV task 的成本降到一行宏,而使用者永远不需要跨过 Verilator 的学习曲线。这就是本方案最核心的价值。

