ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零搭建UVM验证平台:以AHB SRAMC为练手项目

从零搭建UVM验证平台:以AHB SRAMC为练手项目 UVM入门最难的不是看书而是看完书之后不知道自己到底会不会。我自己的UVM自学路走得比较曲折前两个小项目都是在已有环境上改改到后面总觉得差点意思——很多机制phase、sequence、TLM端口看着似懂非懂真正出了问题也不知道该往哪个方向查。所以第三个项目我下了个决心从零开始不抄现成环境完全按自己的理解搭一套能跑通冒烟测试的验证平台练手对象就选AHB SRAMC。选这个模块的原因后面细说先给个结论如果你想检验自己是不是真的掌握了UVM的基本流程AHB SRAMC是个非常合适的“期中考试”题目。它协议复杂度适中DUT逻辑有明确边界而且参考模型特别好写不用花精力在算法上能把注意力全放在UVM机制本身上。这篇文章就把我搭平台的全过程、关键代码片段、踩过的坑和调试经验完整分享出来适合正在学UVM但还没真正独立搭过平台的人参考。1. 项目定位与验证方案设计1.1 为什么选AHB SRAMC作为练手项目先说AHB SRAMC是什么。它挂接在AHB总线上作为从设备slave接收主设备master发来的读写请求然后把AHB协议时序转换成SRAM存储阵列能理解的片选、读写使能、地址和数据信号。简单说它就是AHB总线和SRAM之间的一座桥本身不存数据只负责时序翻译。选它的原因有三层。第一层AHB协议本身的复杂度刚刚好。相比AXI的五个通道、乱序完成、各个通道独立握手AHB要简单得多一个地址阶段加一个数据阶段中间用HREADY来做等待控制。但它又不像APB那样只是一次简单的读写脉冲——AHB有流水线、有burst、有不同传输大小够练手也够踩坑。第二层SRAMC这样一个DUT功能边界非常清晰地址进来、根据读写方向生成SRAM控制信号、处理字节使能没有复杂的状态机大杂烩非常适合用来做行为级参考模型。第三层这类模块在实际SoC里到处都是面试聊到验证实战的时候你能把这个平台的前因后果讲清楚比背八股有用得多。从验证方法论的角度看AHB SRAMC覆盖了UVM验证平台最常见的几种组件形态需要有driver驱动AHB总线需要有monitor采集总线信号需要参考模型预测结果需要scoreboard做数据比对。其中还涉及序列sequence的随机化、寄存器模型的可选集成、功能覆盖率的收集。一套平台做完UVM的知识点基本上就串起来了。1.2 平台组件的划分与数据流搭平台之前先把架构画清楚。我的平台里没有搞太复杂的层次但每个组件的作用都很明确test顶层测试用例负责构建测试场景和启动sequence也是唯一的顶层控制入口。env验证环境负责创建并连接所有子组件同时持有寄存器模型。ahb_agentAHB侧代理内部包含sequencer、driver和monitor三个组件。driver负责把事务转换成AHB时序驱动DUTmonitor负责采集DUT给AHB总线的响应。sramc_monitorSRAM接口侧的采样器采集SRAM控制信号和读写数据用于校验DUT内部的时序行为是否真的正确。ref_model参考模型用行为级数组模拟SRAMC内部行为输入AHB事务输出预期结果。scoreboard计分板把参考模型的预测结果和sramc_monitor的实际采样结果做逐一比对。reg_modelSRAMC配置寄存器模型用于配置DUT比如设置等待周期参数同时也可以被scoreboard引用做配置状态核对。数据流是这样的sequence产生事务对象通过sequencer转发给driverdriver驱动AHB接口。与此同时ahb_agent里的monitor也在采集AHB总线上的读写请求把采集到的事务同时送给参考模型和scoreboard作为预期输入。参考模型根据AHB事务内容更新内部数组并输出预测结果。SRAM侧的sramc_monitor采集DUT对SRAM的实际操作送进scoreboard。scoreboard把参考模型的预测值与SRAM侧实际值以事务为单位做比对一致则通过不一致则报uvm_error。这个平台结构里有一个很多人容易省略的点既然已经有了ahb侧的monitor为什么还要在SRAM侧加一个sramc_monitor原因很简单如果你的比对只在AHB侧看HRDATA返回结果那SRAMC时序错了但碰巧AHB返回数据正确的情况就根本发现不了。比如写使能宽度不对、地址保持时间不足、字节使能错位这些问题在AHB侧不一定暴露得出来。把SRAM侧也采集出来比对的就是DUT内部真实发生的存储行为验证强度完全不一样。1.3 平台目录结构参考工程组织尽量从一开始就规整后边改起来才不头疼。我建议按组件分目录大概是这样ahb_sramc_tb/ ├── dut/ # RTL源码 │ └── ahb_sramc.sv ├── agent/ │ ├── ahb_trans.svh # 事务类 │ ├── ahb_sequencer.svh │ ├── ahb_driver.svh │ └── ahb_monitor.svh ├── ref_model/ │ └── sramc_ref_model.svh ├── scoreboard/ │ └── sramc_scoreboard.svh ├── reg_model/ │ └── sramc_reg_block.svh ├── sequences/ │ ├── ahb_basic_seq.svh │ ├── ahb_burst_seq.svh │ └── vseq_base.svh ├── tb/ │ ├── ahb_if.sv # AHB接口 │ ├── sram_if.sv # SRAM接口 │ ├── sramc_tb_top.sv # 顶层例化DUT和接口 │ └── sramc_env.sv # 环境 └── test/ └── sramc_basic_test.sv目录这件事看起来很基础但实际搭建过程中会不断新增sequence和测试用例。如果从一开始就揉在一个文件里后面filelist管理都是灾难。我的filelist是直接在仿真脚本里逐文件写的文件多了就切到-f参数方式维护一个filelist.f文件这对回归编译速度也有帮助。2. AHB接口驱动与monitor的实现要点2.1 AHB事务对象的字段设计事务类是整个平台的数据载体字段尽量直接对应AHB协议的关键信息。我定义的ahb_trans大概是这样的class ahb_trans extends uvm_sequence_item; rand bit [31:0] addr; rand bit [31:0] data[]; rand ahb_hsize_e hsize; // 0:8bit 1:16bit 2:32bit rand ahb_burst_e burst; // SINGLE/INCR/WRAP4/INCR4等 rand ahb_trans_e trans; // NONSEQ/SEQ驱动时根据burst自动生成 rand bit write; // 1:写 0:读 rand int unsigned wait_cyc; // 用于约束DUT等待周期控制HREADY抖动 constraint c_data_size { data.size() (1 hsize); } uvm_object_utils_begin(ahb_trans) uvm_field_int(addr, UVM_ALL_ON) uvm_field_int(write, UVM_ALL_ON) uvm_field_enum(ahb_hsize_e, hsize, UVM_ALL_ON) uvm_field_enum(ahb_burst_e, burst, UVM_ALL_ON) uvm_object_utils_end endclass字段类型用枚举会比裸int好读很多driver里做时序分支时也不用记数字。data[]用动态数组长度跟着hsize走这样处理窄传输时不会出现数据位宽错乱的问题。事务里还有个容易被忽略的字段事务标识id。我在scoreboard比对时习惯给每个事务加一个递增id便于出错时快速定位是哪一个事务、哪个sequence发出来的。调试体验会好很多。2.2 driver驱动时序的核心逻辑driver是平台里第一个要写好的组件因为它涉及AHB总线的流水线时序写的时候必须把协议吃透。AHB有一个非常关键的特点地址阶段和数据阶段是重叠的。同一个时钟周期内总线上的HADDR是当前事务的地址而HWDATA/HRDATA对应的可能是上一个事务的数据。很多初学驱动写不好本质就是没处理好这个交错关系。我的driver核心循环是这样写的task ahb_driver::run_phase(uvm_phase phase); ahb_trans req; forever begin seq_item_port.get_next_item(req); drive_one_trans(req); seq_item_port.item_done(); // 如果有response需要回在这里调用rsp_port.write(rsp) end endtask task ahb_driver::drive_one_trans(ahb_trans req); // 先看总线是否忙等HREADY为高且上一拍数据阶段完成 // 地址阶段驱动HADDR/HTRANS/HWRITE/HSIZE/HBURST // 数据阶段根据hsize驱动HWDATA或采样HRDATA endtask驱动等待状态的处理是另一个重点。DUT通过把HREADY拉低来插入等待周期master必须保持当前地址和数据不变。具体到driver里就是在HREADY为低的周期保持信号不变化直到采样到HREADY拉高。这里我强烈建议用时钟块clocking block来同步采样和驱动不要用裸的(posedge vif.HCLK)加#1的延迟组合因为它很容易引入仿真竞争。用时钟块的话驱动沿和采样沿在语法层面就分开了至少省一半debug时间。2.3 burst传输的地址推进与wrap回卷AHB的burst可以分成INCR和WRAP两大类地址推进规则不同这是driver里最容易写错的地方。INCR类型每次传输地址在上一次基础上加(1 hsize)直到burst结束。WRAP4则是到了一定边界就回卷比如WRAP432bit传输地址从0x3C开始四个周期分别是0x3C、0x00、0x04、0x08。实现回卷时可以这样算function bit [31:0] calc_next_addr(bit [31:0] cur_addr, ahb_hsize_e hsize, ahb_burst_e burst); bit [31:0] incr; int unsigned wrap_mask 0; incr 1 hsize; if (burst inside {WRAP4, WRAP8, WRAP16}) begin wrap_mask (burst WRAP4) ? 4 - 1 : (burst WRAP8) ? 8 - 1 : 16 - 1; // 按 (burst_len * incr - 1) 对齐 end calc_next_addr (cur_addr ~wrap_mask) | ((cur_addr incr) wrap_mask); endfunction这个函数的重点是先对齐到burst的起始边界再做模运算不能直接相加取余否则地址会跑出去或者回卷位置不对。我在最初版本里就犯过这个错DSIM跑出来波形地址直接从0x3C跳到了0x40WRAP4硬是给整成了INCR4后面查了很久。2.4 monitor采样与事务组包时序monitor的职责是从总线上把真正发生的事务采集出来形成新的ahb_trans对象送往scoreboard。因为AHB有流水线monitor在采样时必须把地址通道信息缓存一拍、等数据通道信息到达后再组包。具体做法用一个task持续跟踪HADDR、HTRANS、HWRITE等信号的变化记录在临时变量里当检测到当前周期与上一拍的地址对应的数据阶段到来时再形成完整事务并ap.write(trans)。monitor还有一个细节陷阱HTRANS BUSY的处理。BUSY状态表示当前传输被打断但并不会产生新的传输类型它与之前NONSEQ或SEQ属于同一个事务。如果monitor把BUSY当成一个新事务发出去scoreboard里就会多出一些莫名其妙的空包。我后来规定只有当HTRANS是NONSEQ或SEQ时才组包BUSY周期只在组包时把数据阶段合并到pending事务里这样就干净了。3. 参考模型与scoreboard的搭建3.1 用行为级数组模拟SRAMC参考模型是整个验证平台里“预期值”的来源。对SRAMC这类存储控制器参考模型不用建模任何内部状态机细节只需要模拟写数据会被存入哪个地址、读数据会从哪个地址取出来。这也是我推荐用SRAMC练手的原因——参考模型的逻辑完全可以抽象成一张地址表。我实现了一个sramc_ref_model类内部维护一个字节数组class sramc_ref_model extends uvm_component; byte mem[int]; // 用关联数组模拟存储空间节省仿真内存 uvm_component_utils(sramc_ref_model) uvm_analysis_imp #(ahb_trans, sramc_ref_model) exp_in_port; uvm_analysis_port #(ahb_trans) pred_out_port; function new(string name, uvm_component parent); super.new(name, parent); exp_in_port new(exp_in_port, this); pred_out_port new(pred_out_port, this); endfunction function void write(ahb_trans t); ahb_trans pred new; // 根据t的写/读、地址、hsize更新mem或从mem取数据 // 生成pred对象通过pred_out_port发出 pred_out_port.write(pred); endfunction endclass关键点在字节使能。AHB上hsize为0时是8bit传输为1是16bit传输为2是32bit传输。参考模型更新mem时不能简单地用一个32bit的int的整体赋值而是要按字节更新。比如16bit写地址0x01数据0xABCD实际写入的内存位置是0x01和0x02两个字节第0字节不动。如果不按字节拆分就会把相邻字节覆盖掉scoreboard比对时永远是错。3.2 比对策略误用uvm_tlm_fifo会踩大坑scoreboard的比对策略我一开始想得比较简单参考模型预测一个事务就往uvm_tlm_fifo里写一个sramc_monitor采到一个事务就读一个比对。跑单发读写的时候一切正常一旦burst和随机sequence上场就开始偶发比对失败。原因是AHB流水线时序下参考模型的预测顺序和sramc_monitor的实际输出顺序不保证严格一一对应——SRAMC内部对burst请求可能会缓一拍或插空数据到达顺序不一定和预测顺序完全同步。后来我把策略改成了按事务id匹配参考模型预测时给事务赋一个递增的idsramc_monitor采样到的事务也带上自己的idscoreboard里用一个队列做缓冲等两边id对上再比对。实现起来不复杂但稳定性好很多最重要的是出错时能直接看到是哪个事务丢失或错乱。对于并发sequence、多个master随机并发这种场景后续还能平滑升级成多队列匹配。3.3 比对失败时的信息打印scoreboard发现不匹配时第一件事是把两个事务的信息完整打印出来不要只打印一句data mismatch。我习惯用一个大uvm_info或uvm_error把地址、读写类型、burst类型、hsize、期望数据、实际数据、事务id全部带上。这样即便不看波形也能定位到大概是谁出了问题。打印格式尽量对齐字段之间用固定分隔符方便用文本工具做后处理。三级流水线、DUT入口缓冲这些细节处理完之后比对失败率从最开始的一堆降到零这个过程中打印信息帮了大忙。4. 寄存器模型、覆盖率与验证收敛4.1 寄存器模型在SRAMC验证里的实际用法AHB SRAMC虽然主体是存储控制但总会有一两个配置寄存器比如设置等待状态的RWH寄存器、可选的地址窗口使能寄存器。这些寄存器用前门访问来配置让DUT处于不同的工作模式然后再注入读写事务。UVM寄存器模型在这里的价值是提供了统一的前门访问API同时用镜像值跟踪软件看到的寄存器视图。说一个寄存器模型里特别容易迷糊的点desired value和mirror value。desired value是sequence希望通过写操作让DUT达到的值而mirror value是UVM认为DUT当前的实际值。如果你通过reg_model做前门写之后调用reg_model.reg_rwh.mirror()它会自动通过前门读出DUT实际值并更新镜像。但如果你的sequence是绕过reg_model直接用driver往地址上写寄存器那reg_model的镜像值就是旧的这时候需要先用predict()或mirror()手动同步否则scoreboard里用镜像值判断寄存器状态就会出错。这个坑我在做带随机预置寄存器场景时踩过后来凡是绕过reg_model的访问一律在完成后显式调一次reg_model.update()或mirror(status)。另一件事是SRAM本身不是寄存器用uvm_mem建模。uvm_mem支持读和写操作也能挂在reg_model的管理下。对于burst访问uvm_mem的write()/read()接口可以指定burst类型在验证存储区间时很顺手。仿真结果看下来把存储区和配置寄存器都收进寄存器模型整个平台对DUT的访问路径就统一了后门初始化也方便。4.2 功能覆盖点怎么定义才不过度设计功能覆盖点是衡量验证是否收敛的重要指标但初学者容易走两个极端要么只写一个地址覆盖点敷衍了事要么堆几百个没人看的交叉覆盖点。我这次定义覆盖点时的原则是覆盖点一定要跟“可能出bug的功能”绑定不跟代码行数绑定。对于AHB SRAMC我重点关注了两类功能点一类是总线侧行为包括HTRANS类型、HSIZE位宽、HBURST类型、以及读写方向另一类是DUT的边界响应行为包括等待周期数分布、非对齐访问、以及窄传输从地址0/1/2/3四个字节位置的命中情况。交叉覆盖我选了三个最有代表性的组合hsize x hburst、hburst x write、以及等待周期数 x hburst。其他的交叉先不建跑几轮回归后再根据盲区决定是否补充。covergroup挂在ahb_monitor里在事务组包完成时触发采样covergroup ahb_cg (ap); cp_hsize: coverpoint trans.hsize; cp_hburst: coverpoint trans.burst; cp_wr: coverpoint trans.write; cp_wait: coverpoint trans.wait_cyc { bins zero {0}; bins one {1}; bins two_three {[2:3]}; bins many {[4:$]}; } cp_addr_byte: coverpoint trans.addr[1:0] { bins b0 {0}; bins b1 {1}; bins b2 {2}; bins b3 {3}; } cross_hburst_size: cross cp_hburst, cp_hsize; cross_wr_burst: cross cp_wr, cp_hburst; endgroup等待周期这个覆盖点很有用因为DUT插入等待是验证时序的关键场景如果随机sequence里很少产生等待SRAMC的时序通路就验证得不充分。我特意在sequence里加了约束让一部分事务故意制造等待周期覆盖率很快就上去了。4.3 回归策略从冒烟到随机收敛平台刚跑通的时候第一件事不是上随机而是先跑几个最简单的定向sequence单发8bit读、单发32bit写、连续两次读、连续两次写。这些定向用例能快速暴露平台本身的连接错误和基本时序问题。等到这些冒烟case都过了再放开约束做随机化跑一个固定seed的回归收集覆盖率看盲区。我的收敛路径大概是第一步固定seed随机化地址、数据、读写方向、burst类型跑500个事务看有没有比对失败。第二步如果失败先查是否平台连接问题比如monitor是否漏采某些事务、scoreboard建模是否有漏字节的情况。第三步用不同的seed跑多轮回归每轮结束后看覆盖率报告针对没覆盖到的bin写定向sequence。第四步把所有定向和随机case合并成一个回归列表设置一个固定的随机种子列表作为正式回归基线。随机化约束里有个细节如果完全不约束地址大量事务会集中在低地址区间SRAMC高位地址译码和高地址读写的覆盖就上不去。我用约束让地址在大范围内均匀分布同时留一些case专门做边界地址0x0、0x3FC、0x400这种不同方向的覆盖就都能照顾到。5. 调试实录与常见坑5.1 phase结束不了objection机制是头号杀手UVM仿真最常见的现象是启动后run_phase立刻结束platform根本没有完成任何事务测试就finish了。原因基本都是sequence里的transaction还没执行完但没有任何机制阻止run_phase退出。我在搭建初期也经历了这个问题现象是仿真log里只看到test和env的build信息然后立刻UVM_INFO : End of test。解决办法是在真正要启动sequence的地方必须raise_objection等所有sequence发完调用drop_objection。我的virtual sequence里写得很明确task vseq_base::body(); uvm_phase phase get_starting_phase(); if (phase ! null) phase.raise_objection(this); fork do_main_seq(); ... join_any // 等所有seq执行完成 if (phase ! null) phase.drop_objection(this); endtask这里还有一个很容易忽略的细节get_starting_phase()返回的是sequence启动所在的phase在UVM 1.2之后更推荐的方式是uvm_phase phase get_starting_phase()同时要记得判空否则在部分仿真器上会报空指针。如果objection相关代码写对了但仿真还是提前结束就检查一下是否有分支只raise没drop不匹配的objection计数会导致卡死在结束阶段这个问题排查起来现象也类似但卡住不是退出还是比较好区分的。5.2 virtual interface为空的经典错误build_phase里访问virtual interface时经常出现null pointer或者Virtual interface resolution failed这是因为interface本身是硬件模块的对象UVM组件不能直接用绝对路径引用它必须通过config_db传递。我在tb_top里这样做module sramc_tb_top; ahb_if ahb0(ahb_hclk, ahb_hresetn); sram_if sram0(); ahb_sramc dut ( .HCLK(ahb_hclk), .HRESETn(ahb_hresetn), .HADDR(ahb0.HADDR), ... ); initial begin uvm_config_db#(virtual ahb_if)::set(null, uvm_test_top.env.ahb_agt.*, vif, ahb0); uvm_config_db#(virtual sram_if)::set(null, uvm_test_top.env.sram_mon, vif, sram0); run_test(sramc_basic_test); end endmodule注意set的路径字符串。uvm_test_top.env.ahb_agt.*里的星号能匹配agent下所有组件driver和monitor都能在build_phase里用同一句get拿到接口不用分别为不同组件set多次。我最初就是分别set给driver和monitor的影子一乱就容易漏。另外如果UVM层次结构里的env名字、agent名字改了这里的字符串也必须同步改维护时要特别小心。5.3 driver不回response会不会卡死这个问题是我平台运行到后来才意识到的driver在驱动完seq_item_port.get_next_item(req)的请求后直接调用了item_done()没有用rsp_port.write(rsp)回response。刚开始sequence也没调用get_response(rsp)所以跑起来毫无问题。但只要某个sequence里写了get_response(rsp)而driver从不写responsesequence就会一直阻塞等待整个仿真就卡在那里了。原因在UVM的sequence-sequencer通信机制里request通道和response通道是独立的get_next_item拿到的是driver要处理的item而get_response等待的是driver通过rsp_port返回的response。如果driver不写responsesequence的get_response会永远等待。实践里的建议是如果你确定不需要response反馈sequence里就不要调用get_response让driver只做item_done握手如果你需要用response携带额外信息比如DUT返回的状态、monitor观察到的重要事件那driver里就必须在item_done前后调用rsp_port.write。两种方式不要混着来混了迟早踩一次卡死的坑。另一个和response相关的体验是即使不调用get_response也不代表driver端可以无节制地拉取sequence的item。如果driver某个分支里只get_next_item不item_donesequence侧的发送窗口很快就会耗尽表现为sequence挂起、driver停下来仿真看起来像死锁。遇到这类“source等了半天没反应”的问题先看driver的握手是否闭合多半就是少了一个item_done。5.4 调试波形时的一个高效技巧用波形调试UVM平台时很多人习惯把整个hierarchy都加进去信号一多反而看不清。我的做法是分层看先在顶层filter只保留AHB总线信号和DUT端口信号确认协议时序和driver/monitor采样是否一致再打开agent内部信号看sequencer和driver之间握手、monitor组包最后如果要查scoreboard比对失败的具体数据流就在波形里加TLM端口上的transaction流用uvm_info打印时间戳和事务摘要对应着看。波形里的采样沿检查也很关键。monitor用时钟块采样如果采样沿和driver的驱动沿重叠很容易竞争。我一般把driver的时钟块设为#1step采样monitor的时钟块也统一避免因为竞争导致采到中间态。这个经验在换仿真器时尤其重要——同一套代码在A仿真器正常到B仿真器出现偶发比对失败八成就是时钟块采样设置没写严谨。整个平台从零写到冒烟通过再跑到回归收敛前后大概花了两周左右的业余时间。过程里踩的每一个坑回过头看都是对UVM机制理解加深的地方。这套平台做完之后我对sequence、phase、analysis port、virtual interface这些概念的理解明显比看书阶段扎实了。如果这台平台你已经跟着搭出来了下一步完全可以尝试把它改造成AXI接口的版本或者给SRAMC加上低功耗控制逻辑再验一遍难度曲线会平缓很多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进