大数跨境

用 Verilator + C++20 协程构建异步协同仿真框架

用 Verilator + C++20 协程构建异步协同仿真框架 ai算法芯片与系统
2026-10-10
6
导读:将每个硬件任务封装为返回异步结果的阻塞接口,仿真全在后台线程运行,主线程调用后直接等待整数结果。使用者无需了解仿真细节,新增任务只需一行宏,实现了时序与异步的分层解耦。

 

背景

在 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 全部锁在一个线程内,从根上避免了跨线程访问 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 数
传统写法
本方案
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 的学习曲线。这就是本方案最核心的价值。

 


【声明】内容源于网络
0
0
ai算法芯片与系统
长期关注ai领域,算法,芯片,软件(系统,框架,编译器,算子库)等联合设计
内容 230
粉丝 0
ai算法芯片与系统 长期关注ai领域,算法,芯片,软件(系统,框架,编译器,算子库)等联合设计
总阅读6.2k
粉丝0
内容230