一份让软件工程师"像调用普通 C++ 库一样调用 RTL"的工程笔记 📓
在芯片项目里,硬件团队交付的是 RTL 模块,软件团队想要的是 一个能调用的函数。中间这道落差,往往成为软硬件协同的瓶颈。本文分享一种实践:把 testbench 封装成函数,并把 RTL 与 C++ 参考模型做成接口一致的等价实现,让软件团队在硬件流片之前就能用上它。 🚀
一、问题的由来:两个世界的语言差异 🌐
1.1 硬件团队交付了什么
以 8 位位串行加法器为例,RTL 长这样:
module seq_adder #(
parameter WIDTH = 8
)(
input wire clk,
input wire rst_n,
input wire start, // 单周期脉冲:启动运算
input wire [WIDTH-1:0] a,
input wire [WIDTH-1:0] b,
input wire cin,
output reg busy, // 运算进行中
output reg done, // 单周期脉冲:结果有效
output reg [WIDTH-1:0] sum,
output reg cout
);
// 每个时钟周期处理一位:sum_bit = a[0]^b[0]^carry
endmodule
对硬件工程师来说,这再自然不过了:时钟推进、握手信号、时序约束——全是熟悉的味道。
1.2 软件团队想要什么
但软件工程师看到这些会一脸茫然 😵:
- ⏰ 时钟是什么?我只想传参数、拿返回值
- 🤝
start / busy / done 怎么用?我的函数又不会"忙"
- 📏
WIDTH 参数怎么映射?我要的是 uint32_t
- 🐛 出错了怎么办?我习惯用异常或错误码
1.3 落差的可视化

这道鸿沟不是技术问题,是抽象层级问题。本文的核心思路就是:用一层 C++ 封装,把 RTL 变成函数。 🎯
二、三层封装结构 🏛️
2.1 整体架构

软件工程师只看最上层,中间的时钟推进、握手等待、状态复位统统封装在类内部。
2.2 各层职责一览
| 层级 |
面向谁 |
关心什么 |
典型内容 |
| 🎯 软件调用层 |
软件 / 算法 / 测试 |
传参、拿结果 |
业务逻辑、单元测试 |
| 🔌 事务级封装层 |
硬件 / 验证 |
封装硬件时序 |
class SeqAdder |
| ⚙️ 信号级驱动层 |
硬件工程师 |
时钟、信号 |
Verilator DUT |
2.3 封装的具体实现
封装的核心是把"信号级操作序列"压缩成"一次函数调用":
class SeqAdder {
public:
struct Result { uint32_t sum; uint32_t cout; };
// 🌟 对外只暴露这一个函数
Result add(uint32_t a, uint32_t b, uint32_t cin = 0){
// 步骤 1:拉起 start 一拍
dut_->a = a & MASK;
dut_->b = b & MASK;
dut_->cin = cin & 1u;
dut_->start = 1;
tick(); // 一个时钟周期
dut_->start = 0;
// 步骤 2:等待 DUT 完成逐位运算
int guard = 0;
while (!dut_->done && guard++ < WIDTH + 4) {
tick(); // 每个周期处理一位
}
// 步骤 3:返回结果
return { dut_->sum & MASK, dut_->cout & 1u };
}
private:
Vseq_adder* dut_;
void tick(){
dut_->clk = 0; dut_->eval();
dut_->clk = 1; dut_->eval();
}
};
2.4 参考模型:接口完全一致
class NativeAdder {
public:
AddResult add(uint32_t a, uint32_t b, uint32_t cin = 0) const{
uint32_t full = (a & 0xFF) + (b & 0xFF) + (cin & 1);
return { full & 0xFF, (full >> 8) & 1 };
}
};
两个类接口一模一样——这是整个方法论的关键 🔑。上层代码只依赖 add(),不依赖实现。
三、封装前后的时序对比 ⏱️
3.1 信号级的繁琐操作

每一步都要小心翼翼,忘一个 tick() 结果就错了 😰
3.2 事务级的干净调用

软件工程师只看到一次函数调用 + 一个返回值 🎉
3.3 调用侧的对比
| 方式 |
代码行数 |
需要理解硬件? |
适合谁 |
| 信号级直驱 |
15+ 行 |
是 😵 |
验证工程师 |
| 事务级封装 |
1 行 |
否 😄 |
软件工程师 |
四、为谁而封:三类典型使用者 👥
4.1 使用者地图

4.2 软件团队:在硬件之前开发驱动 💻
拿到的是一个 .h 文件和一个 .a 静态库:
// seq_adder.h —— 软件团队看到的全部内容
class SeqAdder {
public:
struct Result { uint32_t sum; uint32_t cout; };
Result add(uint32_t a, uint32_t b, uint32_t cin = 0);
};
立刻就能写单元测试:
TEST(AdderTest, BasicAdd) {
SeqAdder adder;
auto r = adder.add(100, 200);
EXPECT_EQ(r.sum, 44);
EXPECT_EQ(r.cout, 1);
}
不需要 Verilog、不需要波形、不需要学仿真器 🎓
4.3 验证团队:用参考模型做大规模回归 🧪
template <typename Adder>
int run_regression(Adder& dut, int n, unsigned seed){
std::srand(seed);
int errors = 0;
for (int i = 0; i < n; ++i) {
uint32_t a = rand() & 0xFF;
uint32_t b = rand() & 0xFF;
uint32_t c = rand() & 1;
auto r = dut.add(a, b, c);
// ... 比对逻辑 ...
}
return errors;
}
// 日常快速回归:C++ 参考模型(亿级规模)
run_regression(native_dut, 100000000, 42);
// 每日构建:Verilator 真值(百万级)
run_regression(seq_adder_dut, 1000000, 42);
同一份测试代码,换个对象就能切换实现 ✨
4.4 算法团队:端到端等价性验证 🧮
// 软件参考流水线
std::vector<uint8_t> software_pipeline(const std::vector<uint8_t>& in);
// 硬件流水线(仿真驱动)
std::vector<uint8_t> hardware_pipeline(const std::vector<uint8_t>& in){
SeqAccelerator accel;
std::vector<uint8_t> out;
for (auto b : in) out.push_back(accel.process(b));
return out;
}
TEST(PipelineTest, Equivalence) {
auto in = generate_random_input(10000);
EXPECT_EQ(software_pipeline(in), hardware_pipeline(in));
}
五、双跑对拍:既快又有保障 🔍
单纯用参考模型加速,会丢掉对 RTL 的实时校验。双跑对拍是一种折衷:
5.1 对拍流程

5.2 实现代码
class CheckedAdder {
SeqAdder rtl_;
NativeAdder ref_;
size_t count_ = 0;
size_t check_every_ = 1024;
public:
AddResult add(uint32_t a, uint32_t b, uint32_t cin){
// 主路径:走参考模型
AddResult r = ref_.add(a, b, cin);
// 抽样校验:偶尔走 RTL
if (++count_ % check_every_ == 0) {
auto rtl_r = rtl_.add(a, b, cin);
if (rtl_r.sum != r.sum || rtl_r.cout != r.cout) {
fprintf(stderr,
"Mismatch @ %zu: a=%u b=%u cin=%u "
"ref=(%u,%u) rtl=(%u,%u)\n",
count_, a, b, cin,
r.sum, r.cout, rtl_r.sum, rtl_r.cout);
abort();
}
}
return r;
}
};
5.3 抽样间隔的选择
| 抽样间隔 |
主路径性能 |
校验覆盖率 |
适用场景 |
| 每次都对拍 |
慢 🐢 |
100% |
关键路径、签核前 |
| 1/1024 |
快 🐇 |
0.1% |
大规模回归 |
| 1/65536 |
极快 ⚡ |
极稀疏 |
压力测试 |
| 仅开头/结尾 |
极快 ⚡ |
采样 |
冒烟测试 |
六、工程细节:让封装真正可用 🔧
6.1 生命周期要明确
Verilator 的 DUT 需要显式清理,和软件工程师习惯的 RAII 不同,封装类要处理好:
class SeqAdder {
public:
SeqAdder() { dut_ = new Vseq_adder; reset(); }
~SeqAdder() { dut_->final(); delete dut_; }
// 🚫 禁止拷贝:DUT 有内部状态,不能随意复制
SeqAdder(const SeqAdder&) = delete;
SeqAdder& operator=(const SeqAdder&) = delete;
};
这样软件工程师能自然地在栈上创建对象:
{
SeqAdder adder; // 自动构造 + 复位
auto r = adder.add(1, 2);
} // 自动析构 + 清理
6.2 接口设计原则清单 📋
- ✅ 用事务级概念(
add、process_packet),而不是信号级概念(start、valid)
- ✅ 用结构体返回值,而不是多个输出引用参数
- ✅ 用一致的错误处理方式(异常或错误码),不暴露 Verilator 细节
- ✅ 用稳定的类型(
uint32_t、std::vector),而不是 WIDTH 相关的宏
- ✅ 保持接口版本化,与 RTL 端口解耦
- ❌ 不要暴露
clk、rst_n、eval()
- ❌ 不要让调用者感知
busy / done 握手
- ❌ 不要在接口里掺杂 Verilog 概念(
$clog2、x 传播)
6.3 交付形态清单 📦
给软件团队交付时,通常提供三种形态:
-
头文件 + 静态库 📚
seq_adder.h + libseq_adder.a
- 简单直接,
g++ main.cpp -lseq_adder
-
CMake 集成 🏗️
- 提供
FindSeqAdder.cmake
- 作为子模块
add_subdirectory 引入
-
Docker 镜像 🐳
七、边界:什么能用,什么不能 ⚠️
7.1 适用性决策树

7.2 场景对照表
| 场景 |
适合封装? |
原因 |
| 🔢 加法器、乘法器 |
✅ 非常适合 |
纯确定性算法 |
| 🔐 CRC / AES / SHA |
✅ 非常适合 |
输入输出清晰 |
| 📊 FFT / 滤波器 |
✅ 非常适合 |
数据通路明确 |
| 🚌 AXI / AHB 从设备 |
⚠️ 部分可用 |
握手时序无法完全建模 |
| 🧠 CPU 核 |
❌ 不适合 |
时序、流水线冲突关键 |
| 🔄 CDC 同步器 |
❌ 不适合 |
跨时钟域语义难建模 |
| ⚡ 时钟门控、DFT |
❌ 不适合 |
C++ 里无对应概念 |
7.3 核心原则
封装是为了让软件团队能在"功能层面"使用 RTL,而不是替代 RTL 验证本身。 🎯
RTL 仿真仍然是签核(sign-off)的唯一真值;C++ 参考模型是"左移"到更早阶段、让更多人参与验证的工程手段。两者是互补关系,不是替代关系。
八、分层测试策略 🏔️
8.1 测试金字塔

8.2 各层对比
| 层级 |
实现 |
规模 |
频率 |
抓什么问题 |
| 🚀 L0 |
C++ 参考模型 |
亿级 |
每次提交 |
绝大多数功能 bug |
| ⚙️ L1 |
Verilator + 随机 |
百万级 |
每次提交 |
结构性 bug |
| 🧪 L2 |
Verilator + 定向 |
千级 |
每日构建 |
协议、时序 bug |
| 🎯 L3 |
RTL + 波形 |
精选用例 |
版本发布 |
签核 |
8.3 CI 配置示例
# .gitlab-ci.yml
unit_test: # 🚀 L0:秒级反馈
script:
- make -C build native_test # C++ 参考模型,上亿向量
only: [merge_requests, main]
rtl_smoke: # ⚙️ L1:分钟级
script:
- make -C build rtl_smoke # Verilator 冒烟
only: [main]
nightly_regression: # 🧪 L2:小时级
script:
- make -C build rtl_full
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
九、落地路线图 🗺️

9.1 每一步的关键动作
-
选试点 🎯
- 选输入输出清晰、时序依赖弱的模块
- 推荐:CRC、定点运算、小滤波器
- 避免:SoC 总线、CPU 核
-
写两份封装 📝
- 一份基于 Verilator(真实 RTL)
- 一份纯 C++ 参考模型
- 接口保持一字不差
-
穷举对拍 ✅
- 小位宽先穷举(8 位 = 65 万种组合)
- 再上大规模随机
- 确保每一组结果都一致
-
交付试用 📦
- 头文件 + 静态库 + CMake 集成
- 一份简短 README:怎么调用、返回什么
- 收集反馈,重点看接口是否好用
-
推广 🌱
- 形成"软件可调用硬件库"
- 每个模块都按统一模式封装
- 逐步覆盖核心数据通路
十、总结 🌟
10.1 核心思想
RTL 是硬件工程师的语言,函数是软件工程师的语言。
把 RTL 封装成函数,就是在这两种语言之间架一座桥。 🌉
10.2 三个关键步骤

10.3 三方收益
| 角色 |
收益 |
| 💻 软件团队 |
不用学 Verilog,像用普通 C++ 库一样使用硬件 |
| 🧪 验证团队 |
可跑上亿量级回归,抽样对拍 RTL 保留校验 |
| 🎯 项目整体 |
功能验证左移,让更多人参与验证 |
10.4 边界提醒 ⚠️
- ✅ 适合:确定性算法、数据通路、软件协同开发
- ❌ 不适合:时序、协议、结构、签核
- 🎯 定位:互补,不是替代
10.5 最后一句
仿真器的速度差异是事实,但本文真正想推荐的,不是"用 C++ 加速仿真",而是"把 RTL 变成软件工程师可以调用的函数"。
这座桥搭好了:
- 软件团队能在流片前跑通代码 🚀
- 验证团队能做更大规模的回归 🧪
- 硬件团队获得更完整的验证生态 🌐
这才是这套实践真正的价值所在。💎