第一章:寄存器模型基础
大家好,我是你们的讲师。今天咱们聊聊UVM寄存器模型。说实话,我刚接触UVM那会儿,觉得寄存器模型就是个花架子——不就是读写寄存器吗?直接拿interface干不就完了?后来被现实狠狠教育了一顿。
你想想看,一个稍微复杂点的芯片,寄存器少说几百个,多则上千。每个寄存器还要考虑复位值、域段、访问权限、后门访问...如果全靠手写driver去对接,那代码量简直不敢想。更别提验证后期要改寄存器定义,那简直是噩梦。
嗯,这就是UVM寄存器模型要解决的问题。它本质上是一套寄存器抽象层,让你用面向对象的方式去描述和操作DUT中的寄存器。
1.1 UVM寄存器模型能干什么?
我总结了一下,寄存器模型主要干三件事:
- 抽象化描述:用类来描述寄存器,包括它的位宽、域段、属性、复位值
- 自动化访问:前门访问(通过总线协议)和后门访问(直接通过HDL路径)都给你封装好了
- 预测与镜像:自动维护一个软件镜像,随时知道DUT里寄存器的当前值
说白了,有了寄存器模型,你的testbench就不用再跟繁琐的总线时序打交道了。你只需要调用reg_model.REG_NAME.read()或.write(),剩下的脏活累活模型帮你干。
核心思想:寄存器模型是DUT寄存器的"影子",你在模型上做什么操作,模型会帮你同步到DUT上。
1.2 三大核心类
UVM寄存器模型里有三个核心类,我习惯把它们比作"零件-板卡-系统"的关系。咱们一个一个说。
1.2.1 uvm_reg —— 最小的零件
uvm_reg代表一个寄存器。它包含:
- 寄存器的位宽(nbits)
- 内部的域段(uvm_reg_field)
- 访问属性(RO, RW, RC等)
- 复位值
举个例子,一个控制寄存器可能长这样:
class ctrl_reg extends uvm_reg;
`uvm_object_utils(ctrl_reg)
uvm_reg_field reserved;
uvm_reg_field enable;
uvm_reg_field mode;
function new(string name = "ctrl_reg");
super.new(name, 32, UVM_NO_COVERAGE);
endfunction
virtual function void build();
reserved = uvm_reg_field::type_id::create("reserved");
enable = uvm_reg_field::type_id::create("enable");
mode = uvm_reg_field::type_id::create("mode");
// 参数:父类、位宽、LSB位置、访问权限、是否易失、复位值、是否有复位、是否可随机化、是否单独访问
reserved.configure(this, 30, 0, "RO", 0, 30'h0, 1, 0, 0);
enable.configure(this, 1, 30, "RW", 0, 1'b0, 1, 1, 0);
mode.configure(this, 1, 31, "RW", 0, 1'b0, 1, 1, 0);
endfunction
endclass
这里有个坑,我当年踩过。你看configure函数那堆参数,顺序千万别搞错。尤其是LSB位置和位宽,写反了仿真不会报错,但读写结果全乱套。我曾经因为这个查了整整两天...
注意:uvm_reg_field的configure参数顺序是:(parent, size, lsb_pos, access, volatile, reset, has_reset, is_rand, individually_accessible)。建议每次写的时候都对照着手册核对一遍。
1.2.2 uvm_reg_block —— 板卡级容器
uvm_reg_block是寄存器的容器。一个block可以包含多个寄存器,也可以包含子block。它负责:
- 管理所有寄存器的创建和配置
- 定义地址映射(通过uvm_reg_map)
- 提供默认的地址解码
我一般这样组织block:
class my_reg_block extends uvm_reg_block;
`uvm_object_utils(my_reg_block)
rand ctrl_reg ctrl;
rand status_reg status;
// ... 其他寄存器
function new(string name = "my_reg_block");
super.new(name, UVM_NO_COVERAGE);
endfunction
virtual function void build();
// 创建寄存器
ctrl = ctrl_reg::type_id::create("ctrl");
status = status_reg::type_id::create("status");
// 配置寄存器(参数:父block,寄存器地址偏移)
ctrl.configure(this, null, "");
ctrl.build();
status.configure(this, null, "");
status.build();
// 创建默认map
default_map = create_map("default_map", 0, 4, UVM_LITTLE_ENDIAN, 1);
// 将寄存器添加到map中(参数:寄存器,偏移地址,访问权限)
default_map.add_reg(ctrl, 32'h0000, "RW");
default_map.add_reg(status, 32'h0004, "RO");
// 锁定配置
lock_model();
endfunction
endclass
注意看lock_model()这个调用。它告诉模型:配置完成了,后面不能再改了。这是UVM推荐的做法,能防止后续误操作。
1.2.3 uvm_reg_map —— 地址翻译官
uvm_reg_map负责把寄存器的逻辑地址翻译成物理总线上的地址。它主要干这些事:
- 管理地址映射关系
- 处理地址对齐和字节序
- 支持多个map(比如不同总线域)
创建map时那几个参数,我解释一下:
| 参数 | 含义 | 我的建议 |
|---|---|---|
| name | map的名字 | 一般用"default_map" |
| base_addr | 基地址 | 如果DUT有多个实例,这里设0,上层再偏移 |
| n_bytes | 总线位宽(字节数) | 32位总线填4,64位填8 |
| endian | 字节序 | ARM用little,PowerPC用big |
| byte_addressing | 是否按字节寻址 | 通常填1 |
小技巧:如果你的DUT有多个总线接口(比如APB和AHB各管一部分寄存器),可以创建多个map。每个map对应一个总线接口,然后通过adapter来桥接。
1.3 寄存器模型与DUT的桥接
模型建好了,怎么跟DUT通信呢?这就需要一个"翻译官"——uvm_reg_adapter。
adapter的作用很简单:把寄存器模型的通用操作(read/write)转换成总线协议的具体时序。比如APB总线,adapter就要把read请求变成PADDR、PWRITE、PRDATA这些信号的变化。
一个典型的adapter长这样:
class apb_reg_adapter extends uvm_reg_adapter;
`uvm_object_utils(apb_reg_adapter)
function new(string name = "apb_reg_adapter");
super.new(name);
// 是否支持字节使能
supports_byte_enable = 0;
// 是否提供物理地址
provides_responses = 1;
endfunction
// 寄存器模型 -> 总线事务
virtual function uvm_sequence_item reg2bus(const ref uvm_reg_bus_op rw);
apb_transfer tr = apb_transfer::type_id::create("tr");
tr.addr = rw.addr;
tr.data = rw.data;
tr.kind = (rw.kind == UVM_READ) ? APB_READ : APB_WRITE;
return tr;
endfunction
// 总线事务 -> 寄存器模型
virtual function void bus2reg(uvm_sequence_item bus_item, ref uvm_reg_bus_op rw);
apb_transfer tr;
if (!$cast(tr, bus_item)) begin
`uvm_fatal("CAST_FAIL", "bus_item is not apb_transfer type")
end
rw.kind = (tr.kind == APB_READ) ? UVM_READ : UVM_WRITE;
rw.addr = tr.addr;
rw.data = tr.data;
rw.status = UVM_IS_OK;
endfunction
endclass
这里有两个函数要成对实现:
reg2bus:把寄存器模型的读写请求转成总线事务bus2reg:把总线返回的结果转回寄存器模型能理解的格式
最后,在环境中把这几样东西串起来:
class my_env extends uvm_env;
my_reg_block reg_model;
apb_reg_adapter adapter;
uvm_reg_predictor #(apb_transfer) predictor;
virtual function void build_phase(uvm_phase phase);
reg_model = my_reg_block::type_id::create("reg_model");
reg_model.build();
reg_model.lock_model();
adapter = apb_reg_adapter::type_id::create("adapter");
// 设置前门访问的sequencer
reg_model.default_map.set_sequencer(apb_agent.sequencer, adapter);
// 设置预测器(用于自动更新镜像)
predictor = uvm_reg_predictor #(apb_transfer)::type_id::create("predictor", this);
predictor.map = reg_model.default_map;
predictor.adapter = adapter;
apb_agent.monitor.item_collected_port.connect(predictor.bus_in);
endfunction
endclass
嗯,到这里,寄存器模型的基础框架就搭起来了。你可以在sequence里这样用:
class my_sequence extends uvm_sequence #(uvm_sequence_item);
virtual task body();
uvm_status_e status;
uvm_reg_data_t data;
// 写寄存器
reg_model.ctrl.write(status, 32'h8000_0001);
// 读寄存器
reg_model.status.read(status, data);
// 后门访问(不经过总线,直接通过HDL路径)
reg_model.ctrl.peek(status, data);
reg_model.ctrl.poke(status, 32'h0);
endtask
endclass
看到没?读写操作变得极其简洁。你不需要关心总线协议,不需要处理时序,甚至不需要知道DUT里寄存器的具体地址。这就是寄存器模型的威力。
总结一下今天的内容:
- 寄存器模型是DUT寄存器的软件抽象,帮你屏蔽总线细节
- uvm_reg是最小单元,描述单个寄存器的域段和属性
- uvm_reg_block是容器,管理寄存器和地址映射
- uvm_reg_map负责地址翻译,支持多总线域
- adapter是桥梁,连接寄存器模型和实际总线协议
下一章咱们聊聊寄存器模型的前门访问和后门访问。这两个概念很多人搞混,其实区别很大。我会结合一个实际项目中的例子来讲——那个项目里,就因为后门访问没用好,导致仿真和芯片行为对不上,差点流片翻车。到时候细说。