13.1 什么是PLI?
一、为什么需要PLI?
Verilog虽然功能强大,但它毕竟是一种硬件描述语言 ,不是通用编程语言。在实际工程中,我们常常会遇到一些纯Verilog难以甚至无法完成的任务:
| 场景 | 纯Verilog的局限 | PLI的解决方案 |
|---|---|---|
| 从外部文件读取复杂格式的数据 | $readmemb/$readmemh格式固定 |
用C语言实现任意格式的数据解析 |
| 与C语言模型协同仿真 | Verilog无法直接调用C函数 | 通过PLI在仿真中调用C模型 |
| 自定义调试工具 | 只能使用有限的系统任务 | 创建自己的 $my_display 等调试工具 |
| 提取设计层次信息 | 无法获取当前模块的父模块名 | 用PLI访问内部数据结构 |
| 生成复杂测试激励 | 激励生成能力有限 | 用C语言生成任意复杂的测试向量 |
| 与外部工具交互 | Verilog是封闭的 | 通过PLI连接MATLAB、Simulink等工具 |
一句话总结PLI :PLI是Verilog的"API接口",让你可以用C/C++写函数,然后在Verilog里像调用 $display 一样调用它们
二、PLI的正式定义
编程语言接口(Programming Language Interface,PLI) 是Verilog标准(IEEE 1364)中定义的一组接口子程序(Interface Routines) 。
通过PLI,C/C++程序可以:
-
读取 Verilog仿真器的内部数据结构
-
写入/修改 Verilog仿真器的内部数据结构
-
提取 仿真环境的信息
三、PLI的几个关键术语
| 术语 | 含义 |
|---|---|
| 系统任务/系统函数 | Verilog中以 $ 开头的内置任务/函数(如 $display、$monitor) |
| 用户自定义系统任务 | 用户通过PLI自己创建的、以 $ 开头的任务 |
| 用户自定义C子程序 | 用C语言编写的、实现用户自定义系统任务具体功能的函数 |
| PLI库子程序 | Verilog仿真器提供的标准PLI接口函数(如 io_printf、tf_putp等) |
13.2 PLI的发展历程
PLI经历了三个发展阶段,每一代都在前一代的基础上增加了更多功能:
一、第一代PLI(PLI 1.0):TF和ACC子程序
这是最早期的PLI实现,包含两类子程序:
| 子程序类型 | 前缀 | 主要功能 |
|---|---|---|
| 任务/函数子程序 | tf_ |
处理用户自定义任务的参数、实用函数、回调机制、输出设备写入 |
| 存储/存取子程序 | acc_ |
提供面向对象的访问,直接读写Verilog结构描述中的对象 |
tf_和acc_的定位 :tf_主要处理"任务调用"相关的事情(参数传递、返回值),acc_主要处理"设计数据"相关的事情(读写信号、遍历层次)。
二、第二代PLI(PLI 2.0):VPI子程序
Verilog 2001标准引入了VPI(Verilog Procedural Interface) 。
| 特点 | 说明 |
|---|---|
| 功能超集 | VPI是 tf_ 和 acc_ 功能的超集,包含了两者的所有功能 |
| 统一接口 | 提供了更统一、更规范的API |
| 推荐使用 | 新设计应优先使用VPI |
VPI vs TF/ACC :你可以把TF/ACC想象成"旧版API",VPI是"新版API"。VPI功能更强大、更规范,新设计应该用VPI。但由于大量遗留代码仍在使用TF/ACC,理解它们仍然有意义。
三、第三代:DPI(SystemVerilog时代)
SystemVerilog引入了DPI(Direct Programming Interface) ,比PLI更简单、更高效。但本书主要讨论传统的PLI。
DPI(Direct Programming Interface)是SystemVerilog(SV)与外来编程语言(如C、C++、SystemC等)之间的接口 。它允许在SV和外部语言之间进行直接、双向的函数调用,你可以把它理解为一个更现代、更便捷的"桥梁",用于连接硬件验证环境和软件世界。
DPI vs. PLI:为什么有了PLI还需要DPI?
在DPI出现之前,Verilog主要通过PLI( Programming Language Interface) 来调用C函数。PLI虽然功能强大,但使用起来非常复杂。DPI就是为了解决PLI的痛点而生的。
| 特性 | PLI (传统方式) | DPI (现代方式) |
|---|---|---|
| 使用复杂度 | 非常复杂。需要编写大量接口代码,并维护一个"注册表"来映射C函数和SV系统任务。 | 非常简单 。只需一行 import 声明即可"导入"C函数,无需繁琐的注册过程。 |
| 函数调用 | 通过$开头的系统任务/函数 (如 $my_task)间接调用。 |
像调用本地SV函数一样直接调用导入的C函数。 |
| 类型安全 | 无参数类型检查,容易出错。 | 支持参数原型检查,编译器可在编译时发现类型不匹配的错误。 |
| 双向交互 | 单向。主要是SV调用C。 | 双向 。不仅SV能调用C (import),C代码也能回调SV中定义的函数或任务 (export)。 |
| 应用场景 | 功能强大,能深入仿真内核,进行底层控制(如强制赋值、事件同步)。 | 适用于**"简单数据"交互** ,在验证环境中集成C/C++参考模型、算法库等。 |
DPI 核心特性与工作原理
1. 两种方向的函数调用
-
import(导入):在SV代码中声明一个用C语言实现的函数。当SV调用它时,控制权就转移到C函数。 -
export(导出):在SV代码中声明一个SV函数/任务,并将其"导出"给C语言调用。
2. 数据类型映射 (Data Type Mapping)
SV和C有各自的数据类型,DPI通过 svdpi.h 头文件定义了标准的映射关系。
-
简单类型 :
int,byte,string等可以直接映射。 -
复杂类型 :SV的
logic、reg、bit、数组等需要映射到DPI提供的特殊类型,如svLogic、svBitVecVal等。
完整代码示例
一个经典的"Hello World"DPI示例。
1. C语言代码 (hello.c)
// 必须包含DPI标准头文件
#include <stdio.h>
#include "svdpi.h"
// C函数实现:打印一条消息
int hello_from_c() {
printf("Hello from C function!\n");
return 0;
}
// C函数实现:计算两个整数的和
int add_from_c(int a, int b) {
return a + b;
}
2. SystemVerilog代码 (top.sv)
module top;
// 使用 "DPI-C" 规范导入C函数
import "DPI-C" function int hello_from_c();
import "DPI-C" function int add_from_c(input int a, input int b);
int result;
initial begin
$display("Entering SystemVerilog...");
// 像调用本地函数一样调用C函数
hello_from_c();
result = add_from_c(10, 20);
$display("Result from C add function: %0d", result);
$finish;
end
endmodule
代码说明
-
import "DPI-C":这是DPI的核心导入声明。 -
function <返回值类型> <函数名>(<参数列表>):声明的形式与SV函数完全一致。调用时也和本地函数一样。 -
编译与运行 :在实际使用中,需要先将C代码编译成共享库(
.so或.dll),然后在运行仿真器时指定该库的路径。
DPI 的主要应用场景
-
集成参考模型:在验证图像处理、加密算法等模块时,直接调用已有的C/C++模型代码作为"黄金参考"。
-
生成复杂激励:利用C语言的强大能力(如文件I/O、复杂算法)生成SV难以构造的测试数据。
-
实现BFM (总线功能模型):用C语言实现复杂的总线协议行为,供SV测试环境调用。
-
连接外部工具:作为SV与MATLAB/Simulink等工具协同仿真的桥梁。
13.3 PLI的工作原理
一、核心工作流程
PLI的工作流程可以概括为四个步骤:
┌─────────────────────────────────────────────────────────────────────────────┐
│ PLI 工作流程 │
│ │
│ 第1步:编写C函数 │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ #include "veriuser.h" │ │
│ │ int my_task() { │ │
│ │ io_printf("Hello from C!\\n"); │ │
│ │ return 0; │ │
│ │ } │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ↓ │
│ 第2步:编译C函数为共享库(.so / .dll)[reference:29] │
│ ↓ │
│ 第3步:将PLI任务注册/链接到仿真器[reference:30] │
│ ↓ │
│ 第4步:在Verilog代码中调用[reference:31] │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ module test; │ │
│ │ initial $my_task; // 像调用系统任务一样调用! │ │
│ │ endmodule │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
二、详细的仿真执行流程
当仿真器执行Verilog代码时,遇到用户自定义系统任务会发生什么?
┌─────────────────────────────────────────────────────────────────────────────┐
│ 仿真执行流程 │
│ │
│ 1. 设计者编写Verilog设计和激励,其中包含用户自定义系统任务调用[reference:33] │
│ ↓ │
│ 2. 编译器和仿真器将Verilog代码转换为内部数据结构[reference:34] │
│ (这种内部数据格式是仿真器私有的,用户不可读)[reference:35] │
│ ↓ │
│ 3. 仿真开始运行[reference:36] │
│ ↓ │
│ 4. 仿真器执行到 `$my_task` 时[reference:37] │
│ ↓ │
│ 5. 执行控制权**转移**给对应的C函数[reference:38] │
│ ↓ │
│ 6. C函数执行完毕后,**返回**控制权给仿真器[reference:39] │
│ ↓ │
│ 7. 仿真继续执行后续代码[reference:40] │
└─────────────────────────────────────────────────────────────────────────────┘
三、内部数据表示
在仿真器内部,Verilog设计被表示为一组对象(Objects) :
| 对象类型 | 举例 |
|---|---|
| 模块实例 | top.u1、top.u2 |
| 模块端口 | top.u1.clk、top.u1.data |
| 原语实例 | and门、nand门 |
| 原语端口(Terminal) | 门的输入/输出引脚 |
| 线网(Net) | wire、wand、wor |
| 寄存器(Reg) | reg变量 |
| 路径 | 模块内部的端到端路径、模块间的路径 |
PLI的acc_和vpi_子程序就是用来访问和操作这些内部对象的。通过PLI,你可以:
-
遍历设计层次(找到某个模块的所有子模块)
-
读取任意信号的值
-
修改任意信号的值
-
获取设计信息(扇出、互联等)
13.4 PLI库子程序
一、库子程序的分类
PLI库子程序主要分为两类:
| 类别 | 功能 | 典型子程序 |
|---|---|---|
| 存取子程序(Access Routines) | 读写或修改设计对象(信号、模块、路径等) | acc_fetch_value、acc_handle_* |
| 实用子程序(Utility Routines) | 数据传输、格式化输出等辅助功能 | io_printf、tf_putp |
二、常用的PLI库子程序
1. 实用子程序(Utility Routines)
| 子程序 | 功能 |
|---|---|
io_printf |
格式化输出到标准输出或文件 |
tf_putp |
将值传递给Verilog任务/函数的输出参数 |
tf_getp |
从Verilog任务/函数获取输入参数 |
2. 存取子程序(Access Routines)
| 子程序 | 功能 |
|---|---|
acc_handle_* |
获取设计对象的句柄(handle) |
acc_fetch_value |
读取对象的值 |
acc_fetch_type |
获取对象的类型信息 |
acc_fetch_name |
获取对象的层次名 |
3. VPI子程序(VPI Routines)
VPI提供了更统一的接口:
| 子程序 | 功能 |
|---|---|
vpi_printf |
格式化输出 |
vpi_get_value |
获取信号值 |
vpi_put_value |
设置信号值 |
vpi_register_cb |
注册回调函数 |
13.5 完整示例:创建 $hello_verilog 系统任务
这是一个经典的"Hello World"示例,展示创建用户自定义系统任务的完整流程。
第一步:编写C子程序
// ============================================================
// 文件名:hello_verilog.c
// 功能:实现 $hello_verilog 系统任务的C子程序
// ============================================================
#include "veriuser.h" // PLI 标准头文件[reference:57]
// 用户自定义系统任务的C实现
int hello_verilog() {
// io_printf 是PLI提供的实用子程序,用于格式化输出[reference:58]
io_printf("Hello Verilog World!\n");
return 0; // 返回0表示成功
}
第二步:注册PLI任务
不同的仿真器注册方式略有不同,但基本原理相同。以下是典型方式:
// ============================================================
// 文件名:veriuser.c
// 功能:将 $hello_verilog 注册到仿真器
// ============================================================
#include "veriuser.h"
// 定义任务/函数条目结构体
s_tfcell veriusertfs[] = {
// 用户任务条目
{usertask, 0, 0, 0, hello_verilog, 0, "$hello_verilog"},
// 结束标记
{0, 0, 0, 0, 0, 0, 0}
};
第三步:编译并链接到仿真器
# Linux/Unix(生成共享库)
gcc -shared -o hello_verilog.so hello_verilog.c veriuser.c -I/path/to/simulator/include
# Windows(生成DLL)
# 具体命令取决于使用的仿真器
第四步:在Verilog中调用
// ============================================================
// 文件名:test.v
// 功能:调用用户自定义系统任务 $hello_verilog
// ============================================================
module hello_top;
initial begin
$hello_verilog; // 像调用内置系统任务一样调用!
$finish;
end
endmodule
仿真输出:
Hello Verilog World!
13.6 PLI的应用场景
PLI的应用非常广泛:
一、自定义系统任务和函数
| 应用 | 说明 |
|---|---|
| 监控任务 | 自定义信号监视器,比 $monitor 更灵活 |
| 激励任务 | 生成复杂的测试向量 |
| 调试任务 | 自定义调试输出工具 |
二、设计信息提取
| 应用 | 说明 |
|---|---|
| 层次分析 | 遍历设计层次,获取模块实例信息 |
| 互联分析 | 提取信号连接关系 |
| 扇出分析 | 计算信号的扇出数 |
三、EDA工具开发
| 应用 | 说明 |
|---|---|
| 波形查看器 | 生成VCD等波形格式 |
| 代码浏览器 | 提取源代码结构和层次信息 |
| 延迟计算器 | 计算路径延迟 |
四、协同仿真
| 应用 | 说明 |
|---|---|
| C模型接口 | 在Verilog仿真中调用C语言模型 |
| MATLAB/Simulink协同 | 与MATLAB/Simulink联合仿真 |
| 外部工具集成 | 连接其他EDA工具 |