背景
在 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
在任何协同仿真系统里,都有两类人:
| 角色 |
关注点 |
需要懂 Verilator 吗? |
| 仿真框架开发者 |
Vtop、VerilatedContext、--timing、协程调度 |
必须懂 |
| 仿真接口使用者 |
调什么函数、拿什么结果 |
完全不需要懂 |
本方案把这两类人的职责硬性切分:
- 框架开发者负责把
Vtop 藏在 Worker 线程里,处理所有时序细节;
- 接口使用者只需要写
int r = engine.task1().get();,就像调一个普通函数。

为什么要做成阻塞? 因为异步语义对不懂 Verilator 的使用者来说毫无意义。他们不关心 co_await、不关心时钟周期、不关心 VlCoroutine 是怎么调度的——他们只想要 result。阻塞接口是最简单、最不容易出错的选择:调用 -> 返回。没有回调、没有状态机、没有额外的心智负担。
整体架构

主线程只做三件事:提交请求、拿到 future、get() 阻塞。它完全不接触 Vtop,也完全不关心仿真时间是怎么推进的。
Worker 线程独占 Vtop 和 VerilatedContext,所有时钟推进、协程调度都在这里发生。这是整个设计的安全边界:Verilator 的 --timing 模式大量依赖线程本地状态,把 Vtop 的构造、eval、final 全部锁在一个线程内,从根上避免了跨线程访问未定义行为。
关键角色
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_top::NAME, \
&Vtop_top::__PVT__##RESULT>(); \
}
这里用的是 C++17 起支持的非类型模板参数:成员函数指针(&Vtop_top::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 数 |
传统写法 |
本方案 |
| 2 |
2 段时钟循环 + 状态机 |
DEFINE_SIM_TASK × 2 |
| 10 |
10 段重复代码 |
DEFINE_SIM_TASK × 10 |
| 新增 |
手抄一段 |
加一行宏 |
使用者新增 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 在仿真内部打印的调试信息。
小结
| 传统方式 |
本方案 |
| C++ 手动驱动时钟 |
Worker 线程统一推进 |
| 轮询 task 完成标志 |
std::promise 一次性通知 |
| 每个 task 手写包装 |
DEFINE_SIM_TASK 宏 |
| 主线程参与仿真 |
主线程只阻塞取结果 |
| 使用者需懂 Verilator |
使用者只需知道 future.get() |
| 无返回值语义 |
std::future<int> 天然异步 |
这套结构把 Verilator 的时序语义、C++ 的异步抽象、使用者的心智模型三者分层:
- 协程负责任务生命周期;
- Worker 线程负责仿真时间;
future 负责跨线程结果传递;
- 使用者只需要面对一个阻塞的、返回
int 的函数。
三者各司其职,新增 SV task 的成本降到一行宏,而使用者永远不需要跨过 Verilator 的学习曲线。这就是本方案最核心的价值。
如果你对这类 Verilator 协同仿真、C++ 异步编程的落地实践有兴趣,也欢迎到 云栈社区 一起交流讨论。