
先交代一下背景。最近在做一个SoC级别的验证项目里面牵扯到好几个AXI主从模块DUT的端口不少、时序也复杂。一开始我直接拿商业VIP套上去能用但心里一直没底——自己没亲手写过AXI接口时序很多异常场景不知道怎么构造出了问题也分不清是VIP的配置问题还是DUT本身的问题。后来下定决心基于UVM从零搭一套AXI验证平台接口时序、事务建模、激励生成、结果比对全部自己来。这篇文章就是把这段经历整理成一份可以直接参考的实战指南。这套验证平台适合谁如果你正在做AXI总线的IP验证或者刚入门验证想找一个相对完整的UVM项目练手再或者你已经用过商业VIP但想搞明白它底层原理这篇文章都值得看看。我会尽量把每一步为什么这么做讲清楚不写空话也不堆概念。1. 项目背景与整体设计思路1.1 为什么选择UVM加AXI这个组合验证领域里UVM已经成为绝大多数数字IC项目的标准方法学而AXI则是SoC内部最主流的片上总线协议。把这两者放在一起几乎是每个做芯片验证的人都会遇到的一个组合。AXI协议本身并不复杂五条通道、握手机制、突发传输看一遍协议文档就能说个大概。真正麻烦的是它支持乱序返回、多通道并发、outstanding传输这些特性叠加在一起之后验证环境的时序控制和数据比对就变得相当棘手。如果只是拿现成VIP跑几个冒烟测试很难真正理解这些机制对验证平台结构带来的影响。自己搭一遍的好处在于每一条通道的握手信号、每一个时序约束都必须亲手处理踩过一遍坑之后再回头看协议文档理解完全不一样。另外商业VIP虽然功能完整但在某些项目里反而不够灵活。比如我想模拟一种非标准的慢速READY行为或者故意制造握手超时来验证DUT的容错能力VIP默认配置不一定支持。自己搭建的实验平台可以自由控制每一个信号这对协议学习、功能调试甚至回归问题定位都非常有用。1.2 验证平台整体架构的确定搭平台之前我先把验证对象想清楚了。这次的项目里DUT是一个AXI SLAVE模块也就是说,外部master会向它发起读写请求DUT负责响应这些请求并完成内部寄存器和存储单元的访问。验证目标主要有三个第一确认DUT能正确响应各种合法的AXI事务第二确认它在非法或边界情况下不会死锁第三确认不同ID的乱序返回不会导致数据错乱。基于这个目标验证平台里必须包含一个master agent来主动发起请求同时还需要一个slave agent用来模拟下游设备便于验证DUT作为master时的行为。这种“两头都搭”的方式虽然多了些工作量但后续扩展性很好任何一端接上真实模块就能复用。整体层次沿用了标准的UVM树形结构从底到顶依次是interface、transaction、driver、monitor、agent、scoreboard、env最后是testcase。我不建议把代码全部压在一个大文件里更好的做法是每个组件一个文件依靠UVM的factory机制在仿真时动态创建。这样每个文件结构清晰编译时间也短排查问题时能快速定位到具体组件。1.3 事务建模先行动手写driver之前我建议先把transaction定义好。这个决策是整个平台的地基transaction里字段定义得是否合理直接决定后面sequence、monitor、scoreboard的复杂程度。我的做法是定义一个通用的AXI事务类里面包含地址、数据、突发长度、突发大小、突发类型、ID、读写标志等基本字段。在此基础上再定义读写两个扩展类读事务额外包含读数据数组写事务额外包含写数据数组和写响应状态。事务类的默认约束要贴近协议合法的边界比如突发长度限制在1到16拍之间突发大小限制在1字节到16字节之间地址对齐方式根据突发类型动态约束。事务字段的命名尽量与AXI协议信号保持一致比如awaddr、awlen、burst等这样在driver里映射信号时几乎不需要做语义转换。数据数组用rand修饰方便在sequence层直接随机出任意数据序列。调试的时候打印事务内容就能直观看出激励生成是否正确。2. AXI协议核心要点与验证接口设计2.1 五条通道和握手机制AXI协议有五个独立通道写地址AW、写数据W、写响应B、读地址AR、读数据R。每一条通道都是典型的VALID-READY握手发送方拉高VALID表示数据有效接收方拉高READY表示可以接收二者同时为高的那个时钟沿传输完成一次。新手最容易犯的错误是在驱动时把VALID和READY的依赖关系搞反。正确做法是VALID一旦拉高不能等待READY拉高之后才保持不变而是应该在VALID拉高后持续拉高直到握手完成才拉低。也就是说VALID不能依赖READY但READY可以等待VALID。这在从零写driver时尤其要注意否则仿真波形里会出现VALID随着READY跳变的情况这是协议明确禁止的。写通道和读通道各有一个地址通道AW和AR完全可以独立发起请求这带来了一个设计上的便利我可以在sequence中同时发起多个读写请求依靠scoreboard根据ID来匹配返回数据进而验证乱序场景。2.2 SystemVerilog interface与clocking blockAXI总线的信号数量非常多直接用一个个logic端口传入driver代码会非常臃肿而且容易出现信号名拼写错误。我选择把完整的AXI接口封装在一个SystemVerilog interface里接口内部按通道分组定义信号同时声明clocking block和modport。clocking block的作用是让驱动和采样信号的时刻点确定下来避免仿真中出现竞争。比如驱动写通道时只有在clocking block指定的时钟沿才能给信号赋新值这样所有组件的驱动行为在时序上是同步的。interface里我同时定义了master方向的clocking和monitor方向的clocking分别给driver和monitor使用。使用clocking block之后驱动代码看起来是这样的vif.m_drv_cb.awvalid 1b1;这种写法比直接操作vif.awvalid更加安全因为所有驱动都自动对齐到了时钟沿不会因为非阻塞赋值的调度顺序产生毛刺。2.3 virtual interface的配置传递interface在UVM组件里不能直接通过构造函数传递标准做法是使用config_db机制。在testcase的build_phase里把interface设置为virtual interface再通过uvm_config_db#(virtual axi_if)::set传到env层。这里有个容易踩的坑set和get的路径必须严格对应。我在环境里习惯把interface配置到具体的agent路径上比如uvm_config_db#(virtual axi_if)::set(this, env.master_agent.*, vif, vif)。这样agent内部的driver和monitor都可以通过同一路径get到。如果配置路径写错仿真往往不会报错只会出现空指针运行期间才会异常退出。为了快速定位这类问题我会在driver的build_phase里get之后主动判断一次if(vif null)并给出打印这个习惯帮我节省了不少排查时间。3. UVM环境层次与组件连接3.1 从transaction到agent的层次划分UVM环境的结构我建议按照agent来组织。一个agent内部包含sequencer、driver、monitor三个组件对外提供两个方向的能力驱动方向和监测方向。开发时先把master agent和slave agent两个agent分别建好然后在env层例化。标准UVM树的层次我最终确定如下testenvmaster_agentmaster_sequencermaster_drivermaster_monitorslave_agentslave_sequencerslave_driverslave_monitorscoreboardreference_modeltestcase在顶层创建envenv负责构建内部所有组件并完成TLM连接。这样testcase里只需要关心激励是什么不需要关心平台内部结构。后面我要增加新的测试场景只需要新增一个sequence不用改动环境结构。3.2 master agent和slave agent的设计差异master agent和slave agent内部结构类似但是职责完全不同。master agent的driver主动发起读写事务slave agent的driver被动接收请求并返回响应。在slave agent的driver里我实现了一个最基本的响应逻辑收到读请求后从预置的内存模型里取出数据然后按一拍一拍的方式返回数据通道收到写请求后把数据存入内存模型同时回一个写响应OKAY。这个内存模型只用了一个关联数组实现地址作为key数据作为value虽然简单但足以支撑读写比对的验证。agent中is_active参数也需要处理。is_active设置为UVM_ACTIVE时agent会创建sequencer和driver设置为UVM_PASSIVE时只创建monitor。搭建环境时我习惯让master agent为ACTIVEslave agent为ACTIVE这样既能发起请求又能回应请求。如果后续有真实slave作为DUT接入只要把slave agent设为PASSIVE即可。3.3 TLM端口连接与analysis_fifo选择组件之间的数据传递我全部通过TLM端口完成。monitor检测到一次AXI事务后通过analysis_port把事务广播出去scoreboard通过analysis_fifo接收。连接代码写在env的connect_phase里用analysis_port.connect(analysis_fifo.analysis_export)把master monitor和scoreboard连起来。这里需要搞清楚uvm_tlm_fifo和uvm_tlm_analysis_fifo的区别。uvm_tlm_fifo两端都有阻塞能力put端可以阻塞调用者get端也可以阻塞读取者。而uvm_tlm_analysis_fifo的写入口是analysis_port写入操作是非阻塞的总是立即返回因此特别适合monitor这种广播侧。scoreboard在读取侧使用get操作如果没有数据到达就会阻塞等待。实际验证环境中monitor发出的数据到达时间完全取决于信号时序根本不可能预测所以非阻塞写入口是必须的。如果强行用uvm_tlm_fifo做写侧可能会因为阻塞导致monitor卡在某个事务上整个验证环境就停在那里了。4. driver与monitor的协议级实现4.1 master driver发送写事务的完整时序写事务的驱动是整个平台里最复杂的部分因为它涉及三个通道AW、W和B。我按照下面这个时序来驱动第一步向地址通道发起写地址。AWVALID拉高同时把地址、突发长度、突发大小等信号放到总线上等待AWREADY回高完成握手。第二步在地址握手完成后驱动写数据通道。WVALID拉高数据逐拍发送最后一拍拉高WLAST。第三步等待B通道返回写响应。BVALID拉高后读取BRESP信号然后拉高BREADY完成握手。这三个通道是可以重叠的。AXI协议允许写数据通道和写地址通道同时进行甚至在收到写地址之前就可以发送写数据只要数据最终能和地址匹配就行。我在driver里用fork join把AW和W通道的驱动并行起来这样可以缩短单笔写事务的耗时也更贴近真实master的行为。等待B通道响应时需要注意AXI协议对写响应时序有要求响应必须在对应的写事务完成之后返回不能提前。如果设计中出现BVALID在WLAST之前拉高就要查一下是驱动的问题还是DUT的时序问题。4.2 monitor采样与事务重建monitor的职责是把总线上实际发生的信号变化还原成事务对象然后通过analysis_port发送出去。这里的关键是捕捉握手完成的时刻而不是简单地在时钟边沿采样信号。以读事务为例我在monitor里先等ARVALID和ARREADY同时为高抓取地址信息然后进入循环等待RVALID和RREADY同时为高每完成一次握手读取一拍数据直到RLAST出现。采集完成后把读数据、地址、ID打包成一个事务对象发送出去。monitor要在接口上同时处理多个通道所以每个通道的监测逻辑放在独立的task里用fork join_none并行起来。这里有个细节事务对象的ID字段要和地址字段一起保存因为乱序返回的场景下一笔读请求的返回数据和另一笔返回数据可能交错到达只有靠ID才能正确匹配。4.3 窄传输、非对齐与outstanding处理AXI协议里的窄传输是指突发大小比总线的数据位宽小的情况。比如总线位宽是32位但每次传输只有1字节有效这时每一拍只更新数据总线的低8位其他位由STRB信号控制是否有效。driver和monitor都需要处理STRB信号尤其是monitor在重建事务时要把STRB有效的那几个字节提取出来对齐到正确的地址偏移上。非对齐访问是另一个容易出错的地方。协议允许地址不按突发大小对齐例如地址0x05、突发大小4字节的传输第一拍数据中只有部分字节属于本次事务。实际项目里很多设计要求地址必须对齐但作为验证环境我仍然实现了非对齐的支持因为这样可以构造边界情况来检查DUT是否做了正确的处理。outstanding传输是指在等待前一笔写响应或读数据返回的同时发出新的请求。验证平台里处理outstanding最简单的方式就是不在driver里做任何阻塞等待一笔事务发出后立刻回到sequencer取下一笔。返回数据的匹配靠scoreboard处理driver不关心。这样既符合协议也让验证平台的性能更高。5. sequence激励生成策略5.1 基础sequence与约束设计激励生成是整个验证策略的核心。我设计了一套分层的sequence结构最底层是base_sequence里面定义了发送单笔事务的公共方法中间层是读写sequence按功能划分最上层是具体的testcase sequence组合出完整的验证场景。一个典型的base_sequence方法长这样创建事务用start_item和finish_item把事务发送给sequencer然后等driver返回。uvm_do宏虽然简洁但有时候我需要手动控制事务的内容比如从寄存器模型里读取地址所以我更习惯直接用start_item的写法。这两种方式本质没有区别选择标准主要看可读性。约束设计上我会为随机地址加上边界约束让地址在0、最大值、页边界附近出现的概率更高。AXI的突发访问最怕跨页边界时地址回卷出错所以这个约束对捕获设计缺陷很有帮助。数据也做成随机但不做过于复杂的权重设置随机数据足以覆盖大部分数据通路问题。5.2 乱序与多通道并发场景验证乱序返回的最佳方法是同时发出多个不同ID的读写请求。我专门写了一个outstanding sequence一次性发出几笔ID各不相同的读请求然后让scoreboard根据ID延迟匹配返回数据。由于sequence和sequencer之间是单通道的driver一次只能取一笔事务。如果我想让多个master同时向DUT发起请求就必须例化多个agent。我在环境里默认例化了两个master agent一个叫master_0另一个叫master_1两个agent分别运行各自的sequence模拟多master并发访问的场景。这种结构对验证仲裁逻辑特别有价值。编写多master场景时还要注意两个master之间的时间关系。如果两个master同时向同一地址发起读写谁先谁后会影响最终结果。为了避免测试结果不确定我会在scoreboard里做地址冲突检查如果发现同一时刻有两个master访问同一地址就会打印一个警告提示检查这个地址是否有互斥访问保护机制。5.3 用sequencer仲裁多个激励源不同sequence的请求可以用sequencer的仲裁机制来控制优先级。通过设置sequence的priority参数可以让高优先级的sequence插队。这个机制很适合构造优先级覆盖场景比如正常业务流量的同时周期性插入一个高优先级的中断处理事务。我在实际项目里就是这么用的一个低优先级的随机流量sequence持续运行另一个高优先级的目标地址写sequence定期启动。启动高优先级sequence时如果中间有一笔低优先级事务正在执行driver会先完成当前事务然后执行高优先级事务。这个行为是UVM sequencer的默认仲裁策略不需要额外配置。需要提醒的是priority仲裁是seqtender层面的它只决定哪一笔事务先进入driver不决定总线上是否完成乱序。真正要验证总线上并行事务的顺序还是要靠多个agent并行来实现。6. scoreboard与参考模型设计6.1 参考模型职责划分参考模型是整个验证平台正确性的最后一道防线。我的参考模型实现得非常简单内部维护一个内存模型当master monitor发来写事务时按照地址和数据更新内存当发来读事务时从内存里取出期望的数据和DUT实际返回的数据做比对。这里有一个关键点参考模型的更新顺序必须和总线上实际事务的完成顺序一致。由于AXI支持乱序如果参考模型只按照transaction到达的顺序更新内存碰到乱序事务就会计算出错误期望值。我的处理方式是参考模型只在写响应或读数据真正完成的时候更新内存也就是说监控到B通道握手完成时更新写数据监控到R通道数据返回时读取内存。这样即便请求乱序内存模型的更新顺序也和实际一致。6.2 数据比对策略scoreboard的比对策略我分成了三层请求层、数据层和响应层。请求层比较的是monitor抓到的写地址事务和sequence里发起的事务是否一致确保driver没有在传输过程中修改事务内容。数据层比较的是读数据返回是否符合预期这个比较需要等待DUT返回数据所以用两个analysis_fifo缓存读写请求读数据到达时再从期望内存里取对应地址的数据。响应层比较的是写响应BRESP信号如果DUT返回非OKAY比如SLVERR或DECERRscoreboard会立即打印错误。比对时机也要注意。DUT返回数据和参考模型期望数据可能在时刻上完全不对齐如果用非阻塞的try_get就可能因为数据还没有生成而丢事件。我选择使用阻塞get操作让scoreboard的run_phase持续等待数据到达这样不会丢事务也不会因为轮询浪费仿真时间。6.3 寄存器模型镜像值在平台中的作用如果DUT里包含可配置寄存器我会在平台上集成UVM寄存器模型。寄存器模型的镜像值机制对验证非常有价值它维护了一份寄存器当前值的软件副本。调用mirror可以让模型主动从DUT读回寄存器的实际值并与本地镜像值比较。镜像值更新的触发方式通常通过一个predictor来完成。predictor监听总线上的读写事务每当监测到对某寄存器地址的写操作时就更新对应寄存器的镜像值。这样软件可以通过寄存器模型随时知道DUT里寄存器的实际状态而不需要手动记录。集成寄存器模型之后sequence里的地址字段就不能完全随机了而是要从寄存器模型的地址映射中获取。比如我想写某个控制寄存器直接从对应寄存器句柄上调用write任务平台会自动找到地址并生成事务。这比手写地址更不易出错也方便后续做寄存器覆盖率统计。7. UVM phase机制与仿真运行控制7.1 phase流程与objectionUVM phase机制保证了组件在正确的仿真阶段完成各自的初始化和运行。build_phase自顶向下执行connect_phase在build之后自底向上连接run_phase才是真正产生激励和完成比对的时间段。run_phase不会自动结束它需要等所有组件主动声明“我已经干完活了”。这个声明机制就是objection。sequence发送完毕、scoreboard比对完成、driver没有待处理事务所有这些条件都满足后testcase在run_phase里调用drop_objection仿真才能进入后续的report和结束阶段。我在平台里通常会把objection的raise放在sequence的body开头drop放在body结尾。这样只要sequence执行完毕仿真就会自然结束。但随机化的sequence可能执行得非常快快到仿真还没来得及进入run_phase就结束了所以要在raise_objection和drop_objection之间加一个小的#delay或等待确保仿真真正跑起来。更稳妥的方式是把objection放在一个独立的phase_runner sequence里由它来统一控制整个testcase的运行时长。7.2 防超时与失控测试的配置验证平台最让人头疼的一个问题是一个错误条件可能让仿真卡住不动跑几个小时都不结束。为防止这种情况我在顶层testcase里设置了set_timeout比如限制仿真时间不能超过1毫秒。如果DUT在某个握手信号上卡死timeout会触发错误并终止仿真这样就能快速暴露问题。除了整体timeout我还会在每个driver的run_phase里加一个边带计数器。如果等待某个READY信号超过比如128个时钟周期就打印一条warning并把当前事务的状态打出来。这个统计信息在定位死锁场景时非常有用。7.3 常用run_phase写法driver的run_phase我写成了独立的task循环每当从sequencer拿到新事务就调用对应的驱动子程序。monitor的run_phase则构建为多个fork join_none的并发监控task。scoreboard的run_phase会持续从fifo里取事务做比对。对于需要重复运行多个随机场景的测试推荐在testcase的run_phase里用repeat循环创建sequence每轮结束后加少量时钟周期间隔。这样方便统计回归结果也容易在失败时通过打印的事务序号快速定位是哪一轮场景出错。8. 调试技巧与打印控制实战8.1 UVM打印等级与transaction打印关闭UVM提供了丰富的打印控制能力但很多新手的做法是到处插$display结果仿真日志动辄几百MB真正有用的信息反而被淹没了。更规范的做法是使用uvm_info并按照UVMLOW、UVM_MEDIUM、UVM_HIGH、UVM_FULL等verbosity等级来组织打印信息。在实际调试中我建议默认verbosity设为UVM_MEDIUM只有需要深入排查时才调高到UVM_DEBUG。打印等级的设置可以通过UVM_VERBOSITYUVM_DEBUG或uvm_root::get().set_report_verbosity_level_hier实现。如果想关闭特定组件的transaction打印可以单独把它设为UVM_NONE。如果是Synopsys AXI VIPtransaction打印量会非常大。我实测下来最有效的方式是在启动时把全局verbosity设为UVM_NONE然后单独把scoreboard或reference model的verbosity拉高。另外VIP通常提供set_report_id_verbosity这样的接口可以指定打印的ID比如“AXITRANS”这一类ID单独控制。通过组合全局verbosity和ID级verbosity就能做到既不丢失关键信息又不会让日志爆炸。8.2 AXI Traffic Generator配置要点如果项目使用了Synopsys的AXI Traffic Generator配置主要集中在三个地方一是地址映射定义每个master可以访问的地址范围和对应的ID二是传输模式设置为固定突发、随机突发还是全随机三是注入速率控制事务间的最小间隔模拟不同的带宽占有率。我在图形界面上配置时会把地址范围设为DUT实际映射的memory区域并启用随机数据生成。传输模式选“adaptive”这样generator会根据DUT返回的ready信号自动调整发送速率更贴近真实负载。界面里还有一个burst length设置项默认值往往是16做压力测试时我会把它调成随机范围更充分。8.3 波形定位与死锁分析定位AXI死锁问题我通常会先打开波形找到第一个长时间不再变化的信号再从这个信号往前回溯。比如R通道死锁可能是RREADY或者RVALID一直没有拉高。查看这两个信号的波形就能迅速判断是等待哪一方。很多死锁其实不是协议层问题而是复位异常或时钟未跑起来。所以排查顺序一般是先看时钟和复位再看各通道的VALID-READY最后看数据对比。我还会在波形里把master agent和slave agent的信号都打开用颜色区分不同通道调起来非常直观。9. 常见问题排查实录问题现象可能原因排查与解决方法仿真结束但没有任何事务发起testcase中没有raise objection或者sequence没有发送到正确的sequencer检查objection有没有在sequence里正确raise/drop检查sequence的start调用的sequencer路径driver卡死在等待READY对端monitor或slave driver没有驱动READY信号查看波形中READY是否一直为低确认slave agent有没有被正确例化读数据比对错误参考模型更新顺序和乱序返回不一致检查参考模型的内存更新时机改为在B/R握手完成时再更新配置了virtual interface但组件内为空config_db路径写错或set/get顺序不对在build_phase里get后判断null并打印路径确认env层次名称一致打印日志爆炸verbosity级别太高或Transaction打印未关闭使用UVM_VERBOSITYUVM_NONE再单独提升关键组件Verbosity仿真卡住不退出DUT握手逻辑异常或timeout未设置设置set_timeout并在driver中加握手等待超时计数乱序transaction在scoreboard里无法匹配没有用ID字段做匹配确保transaction里ID字段被正确传递scoreboard按ID分类存储验证条款验证点介绍握手信号检查VALID-READY握手时序、VALID不能依赖READY、READY可以等待VALID突发传输检查固定突发、增量突发、回卷突发的地址和数据正确性数据通道检查WLAST、WSTRB、窄传输、非对齐访问的数据提取乱序返回检查不同ID的请求返回顺序是否与协议一致响应通道检查写响应BRESP、读响应RRESP的返回条件和状态outstanding检查多笔未完成事务场景下DUT能否正确响应AXI4AXI3支持Outstanding传输支持但数量较少支持乱序返回要求同ID乱序返回去掉写响应BRESP信号使用WSTRB写响应BRESP保留支持一次突发最大16拍支持一次突发最大8拍支持窄突发传输支持窄突发传输在实践中我还踩过一个坑就是给master agent和slave agent都用到了同一个clocking block结果monitor采样到的数据总是晚一拍。后来发现clocking block里用到的时钟信号必须独立声明否则两个agent会共享同一个时序对象导致采样点冲突。改成每个agent各自维护一个clocking block之后问题立刻消失了。最后再分享一个小技巧。每次跑完回归我会把所有UVM_ERROR和UVM_WARNING的统计信息单独抓出来归类整理。很多看似不相关的warning其实指向同一个底层问题。比如多个读写比对错误根因可能只是复位信号在interface里被反了。把这些统计信息做好分类下一代平台的维护会轻松很多。