
做APB Timer验证环境搭建这个系列写到这篇已经是第六篇了。前面几篇把APB总线时序、Timer模块功能规格、接口和driver都拆开聊过这篇我打算把环境主链路完整串起来重点讲参考模型、计分板和testcase设计最后附上我在调试过程中踩过的一些坑。如果你正从零开始搭UVM验证环境我建议先别急着怼代码把Timer这个外设的关键行为想清楚——预分频怎么走、自动重装怎么触发、中断怎么清除——因为参考模型写得好不好直接决定整个环境的可信度。环境本身不难难的是把“这个模块该怎么工作”这件事用代码表达得和RTL一致。1. 环境设计前的思路梳理先搞清楚Timer到底考什么1.1 从功能验证点反推环境组件搭验证环境最忌讳上来就写代码。APB Timer虽然是APB外设里比较简单的一类但它的验证点并不少我当时梳理下来至少有这几块APB接口的读写时序、预分频系数对计数频率的影响、定时器使能和禁用行为、比较匹配后的中断产生、自动重装模式下计数器的装载逻辑、以及中断状态寄存器的清除方式。每个验证点都对应环境里至少一个组件而不是靠一两个testcase硬怼。比如预分频和计数行为需要参考模型来产生期望值中断匹配需要监控中断引脚和中断状态寄存器寄存器读写则依赖driver和sequence的配合。先列验证点再定组件最后才写代码这个顺序能省掉后面大量返工。1.2 环境组件清单与层次划分UVM环境搭起来其实就那么几类东西接口和transaction负责数据传递driver和monitor负责时序sequencer负责调度agent做封装env把各组件串起来然后再加参考模型、计分板、测试用例。下面是我这套环境的组件清单也是大多数APB外设验证环境的通用结构。组件层级组件名称主要职责接口层apb_if定义APB信号、时钟块和modport供driver和monitor采样驱动数据层apb_transaction地址、数据、读写方向、等待状态等信息通信层apb_driver把transaction变成APB协议时序处理PREADY握手通信层apb_monitor采样总线上的读写事务并广播给其他组件通信层apb_sequencer承载并调度sequence产生的transaction封装层apb_agent把driver、monitor、sequencer封装成可复用单元环境层timer_env将agent、参考模型、计分板、寄存器模型如有连成整体行为层timer_ref_model根据寄存器配置模拟Timer的计数、匹配、中断等行为检查层timer_scoreboard比较参考模型输出与DUT实际输出用例层timer_base_test等配置环境、启动sequence、控制仿真流程分层的好处是复用性高。APB agent在这个项目里能用作master来驱动总线后面如果做其他APB外设比如APB GPIO、APB UART可以直接搬过去只要换掉参考模型和计分板就行。1.3 为什么用UVM而不是直接写定向testbench有的同学可能会有疑问一个Timer而已直接写个top-level testbench发几组激励、看几个波形不就行了如果是做一次性验证确实可以。但Timer这种模块恰恰是“看着简单、细节多”的类型——预分频的边界值、中断的清除时序、自动重装的非对称行为只靠手点波形很容易漏。UVM的价值在于随机约束、覆盖率收集和环境复用这些不是炫技是真实项目中发现bug的基础手段。另外从学习角度讲APB Timer是练习UVM方法学的绝佳项目接口简单、行为清晰、参考模型好写但又涵盖了driver握手、monitor采样、scoreboard比较、sequence控制这些核心机制。这一套练熟了后面接手复杂模块的验证环境至少不会被环境代码本身绊住。2. 地基部分接口与事务定义里的细节坑2.1 接口时钟块是采样时序的“安全网”APB接口定义是整个环境的地基这块没写好后面所有组件都会跟着遭殃。我见过很多刚入门的同学不习惯用clocking blockinterface里直接放信号driver里用(posedge pclk)手动采结果到了PREADY反压场景就出现采样竞争和时序含糊。我的做法是接口里明确划分driver和monitor两套时钟块。Driver侧的时钟块负责输出信号monitor侧的只采输入信号这样采样时刻就固定在时钟上升沿的同一个delta cycle避免因为事件调度顺序导致读到不稳定值。modport再把这套关系固定下来agent里面引用接口时就不会用错方向。2.2 transaction的字段设计不要只顾当前需求APB的transaction字段本身不复杂地址、数据、读写方向再加上必要的控制字段。但这里有一个我在实际使用中总结的经验宁可一开始多留几个扩展字段也别等后面加需求再回头改。比如APB可能有slave选择信号、保护信号、错误响应标志当前Timer可能用不到PADDR的高位但你在driver和monitor里把这些字段透传好以后这套agent复用到别的APB外设时就不用改结构。字段的约束也要提前想好。比如地址要对齐到4字节这在APB上通常都是硬性要求可以在sequence里约束但我建议直接在transaction的constraint里写死这样后面写随机测试时不会因为地址不对齐导致总线协议违例。2.3 driver和PREADY握手的两个关键点APB的driver核心是时序生成IDLE状态后拉高PSEL进入SETUP一个周期后拉高PENABLE进入ACCESS然后等待PREADY拉高完成传输。代码层面不复杂但有两个关键点很容易被忽略。第一是PREADY的默认值。如果DUT在复位期间或者未选中时PREADY是低电平而driver在psel拉高前的等待循环里判断PREADY就可能卡死。所以driver里握手循环的起始条件、复位后的信号默认值必须和验证计划里的假设一致。第二是读写共用一套时序逻辑但回来的数据路径不同。写传输的PWADTA在SETUP阶段就要稳定读传输的PRDATA在ACCESS阶段由DUT驱动。driver里务必在PREADY拉高的那个时钟沿采样PRDATA然后在下一拍拉低PSEL和PENABLE完成整次传输。3. 环境主链路参考模型和计分板是环境的“裁判”3.1 Timer参考模型怎么设计才靠谱参考模型是这套环境里最核心的行为描述我建议用寄存器配置的视角来建模。Timer的行为无非是CPU通过APB接口配置控制寄存器、预分频寄存器、比较寄存器然后内部计数器按照预分频后的时钟走计数到比较值时拉中断使能自动重装的话再装载初值继续跑。参考模型里维护一份“寄存器镜像”每当APB侧来了写事务就更新寄存器值收到读事务返回当前镜像值。然后单独起一个计数行为任务根据控制寄存器的使能位、预分频系数、比较值来更新计数值和中断状态。这样参考模型和DUT的行为是对齐的因为DUT也是同一套架构。预分频的处理是最容易出错的地方这里要细说。假定预分频寄存器的值是div实际计数器按每(div1)个PCLK周期计一次那参考模型里要用一个内部的经过除法后的使能信号来控制计数值的递增而不是每个时钟都加一。我当时就是在这里偷懒直接每周期加一导致比较中断的时间点全部对不上。3.2 参考模型代码框架里藏着哪几个关键变量参考模型不用完整复刻RTL实现但关键的内部状态必须有计数值count、预分频counter、中断状态、自动重装标志。下面是我参考模型的骨架重点看预分频和中断清除这两块。class timer_ref_model extends uvm_component; // 寄存器镜像 reg [31:0] tcr, tpr, tcmpr, tcvr, tisr; // 内部行为状态 int prescale_cnt; int timer_cnt; bit interrupt_pending; function void write_register(string name, bit [31:0] data); case (name) TCR: begin tcr data; if (!tcr[0]) begin // 使能位拉低时停止计数但保留计数值 timer_cnt tcvr; end else begin // 使能位拉高从TCVR当前值继续计数 timer_cnt tcvr; prescale_cnt 0; end end TPR: tpr data; TCMPR: tcmpr data; TISR: begin // 中断状态寄存器写1清除 if (data[0]) begin interrupt_pending 1b0; tisr[0] 1b0; end end endcase endfunction task run_phase(uvm_phase phase); fork begin : timer_counting forever begin (posedge vif.pclk); if (tcr[0]) begin if (prescale_cnt tpr) begin prescale_cnt 0; timer_cnt timer_cnt 1; end else begin prescale_cnt prescale_cnt 1; end // 匹配比较值 if (timer_cnt tcmpr) begin interrupt_pending 1b1; if (tcr[1]) timer_cnt tcvr; // 自动重装 end end end end join_none endtask endclass这段代码里有两个细节值得注意。第一使能位拉低后只是停止计数不清零这样重新拉高时从TCVR当前值继续才能模拟真实硬件的“暂停”语义。第二中断状态在匹配时拉高之后即使计数器继续走也不应该被清掉必须等软件写TISR清除。很多初版参考模型都在这里写错导致计分板报出一堆假失败。3.3 计分板比较时怎么处理“预期值产生时刻”和“实际值采样时刻”计分板的核心思路不复杂参考模型产出一个期望值放入一个期望FIFOmonitor采集到DUT的实际值后从FIFO里取期望值来比较。但这里有一个时序对齐问题如果你没想清楚调试起来会非常痛苦。以读寄存器为例。当DUT收到一个读TCVR的请求时它返回的是当前计数器的瞬时值。参考模型同样在这个读事务的响应时刻应该输出当前计数器的镜像值。这两个值在同一个仿真时刻产生比较才成立。如果你在写读事务的sequence发起端就采样TCVR然后过几个时钟才比较计数值早就变了当然对不上。所以我建议计分板里的比较触发点要放在monitor采集到complete的读事务之后由读事务的响应数据去和参考模型在“同一transaction ID”下产生的读返回数据比较。对于中断和计数的周期性行为则定期采样DUT中断引脚和寄存器与参考模型的状态比对。总之比较的时基必须统一这是计分板不出假错的前提。3.4 中断检查不能只看电平要看清除流程APB Timer的中断通常是电平型匹配后拉高软件写中断状态寄存器的对应位来清除。我见过有人只在中断拉高时检查一次就完事结果中断清除路径的bug完全没暴露。正确做法是把中断当做一个完整的状态机来检查初始为0匹配后变1写清除后变0如果是自动重装模式清除之后再次匹配还能再次拉高。计分板要覆盖这个完整流动而不是停在“中断曾经拉高过”这个结论上。清除中断的sequence里也要注意写TISR的值通常只有bit0有效其余位要么保留要么写0不能随手写个全1糊弄过去。4. Testcase设计从跑通到跑全面4.1 base_test里把公共流程沉淀下来环境搭好后testcase最忌讳的是每个用例里都重复build、connect、启动sequence这些公共代码。我在这个项目里先写了一个timer_base_test把环境实例化、接口配置、打印级别、仿真时长都集中放在里面所有具体testcase只负责override transaction的约束、启动针对性的sequence、以及注册覆盖率收集对象。这个习惯在项目早期可能感觉是“多此一举”但用例到10个以上时base_test里统一管理的好处会非常明显。比如你想加一个全局的超时判断只改base_test一处就行不用每个testcase都去改。4.2 基础读写测试用“读回验证”把寄存器通路打实寄存器读写是Timer的根基寄存器都读写不对后面功能测试全是空中楼阁。这类测试要覆盖三件事写入的值能读回来、复位默认值正确、保留位不受写操作影响。做法上我一般先写一个遍历所有寄存器的sequence对每个寄存器依次做写-读-比较再针对边界值做一轮随机化写入。保留位的问题特别容易被忽略比如TCVR在什么时候可写、TISR是不是只读从0接口返回这些都要对照寄存器描述逐一确认。如果你有寄存器模型还可以用前门访问来自动化这件事省掉手动枚举的体力活。4.3 功能测试的三类场景计数、匹配、自动重装功能测试我拆成三个独立场景每个场景单独起一个testcase这样定位问题的时候不用猜是哪个路径出的问题。计数场景使能定时器后每隔一定周期读一次TCVR确认计数值递增再关闭使能确认计数值冻结。这里要注意预分频系数不要上来就用最大值先用0和1验证基础计数路径再用较大值验证分频逻辑。匹配场景设置一个不算太大的比较值使能定时器后等待中断拉高。检查中断拉高的时刻和计算出的匹配周期一致。再把TISR清除后确认中断能再次拉高这能验证比较值匹配不是一次性事件。自动重装场景使能自动重装位匹配后观察TCVR确认被装载回预设的初始值而不是停在匹配值或继续从匹配值加一。这个场景如果能用APB接口读TCVR来确认更好直接驱动内部信号的方式容易掩盖RTL的实现问题。4.4 随机测试和覆盖率别为了覆盖率而覆盖率当定向用例都跑通之后就要上随机约束了。随机测试的价值在于用机器代替人遍历大空间但前提是约束写得合理不然随机出来的全是非法配置没一点验证价值。我在这套环境里重点收集的覆盖点包括TCR使能位和自动重装位的交叉、预分频系数的边界和中间值、比较值与计数值的相对大小大于、等于、小于、中断清除方式写1清除、写0保持。这些覆盖点之间是可以交叉的比如“使能同时打开预分频和自动重装”和“比较值等于初始计数值”的组合往往就是设计容易出问题的角落。覆盖率收集过程中我遇到的一个真实问题是自动重装和中断清除这两个分支长时间都是空的因为随机sequence很少会在匹配之后立即清除中断再触发第二次匹配。后来我加了一个专门的“双触发”sequence才把这条路径补上。覆盖率不是用来凑数的它帮你发现的是“你压根没想到的场景”。5. 调试与踩坑实录环境跑不起来大多卡在这几处5.1 第一轮仿真最常见的“时间零点死锁”第一轮跑仿真很多人的环境会卡在0时刻不动日志里看不到任何东西。这通常不是逻辑写错而是UVM的objection机制没用对。run_phase里启动了sequence但phase没有raise objection仿真器认为没有活动就提前退出了。解决办法也简单在base_test的run_phase里对所有sequence的启动包一层raise_objection和drop_objection或者用uvm_sequence的start方法配合default_sequence机制。我这套环境里是把所有sequence的启动封装在timer_base_virtual_seq里由virtual sequencer调度objection统一管理后续新增用例就不会再遇到这种低级问题。5.2 PREADY死锁看似是环境问题实际上是初始化问题driver里while(!pready)的等待循环是APB验证环境最经典的死锁点。我排查过好几次这类问题最后发现根因大多不是一个地方要么是复位释放后DUT的PREADY还没有拉高而driver一上来就尝试发起第一次传输要么是PSEL都没拉高DUT根本不会响应PREADY而driver内部逻辑里对PSEL和PENABLE的赋值顺序写反了先去等PREADY再拉PSEL死锁就这么发生了。以我调APB Timer的经验遇到死锁先别急着看driver代码先在波形里看PSEL、PENABLE、PREADY三个信号的相对时序基本就能定位是哪一种情况。还有一种隐蔽情况是仿真器的initial块里把PREADY初始化为XX在条件判断里既不等于0也不等于1while循环直接卡死。所有接口信号请务必在复位阶段赋上确定的初始值。5.3 计分板假失败比较逻辑的时基没对齐计分板报错但功能明明是好的这类问题最容易让人心态炸裂。我遇到过的典型场景是参考模型还在等APB写事务更新的寄存器值DUT的计数器却已经按新配置跑了好几个周期导致后续比较全部错位。这个问题本质是参考模型和DUT对“配置生效时刻”的理解不一致。真实硬件里APB写传输在PENABLE与PREADY都为高的那个上升沿更新寄存器并生效参考模型也必须在monitor采样到这个事务的时刻同步更新而不是在事务进入FIFO之后再慢慢处理。解决方案是在参考模型里直接通过apb_monitor的分析端口接收事务保证参考模型的更新和DUT接收配置在同一个仿真时刻发生从根上消除错位。5.4 覆盖率空洞排查最大“贡献者”是中断清除路径覆盖率数据出来之后我发现中断清除相关的覆盖点空洞得厉害。原因是随机sequence里虽然会产生中断但清除中断只在专门的sequence里做了随机sequence很少会自动去读TISR、写TISR。这个现象背后反映了一个问题验证环境中各sequence之间的组合关系不够丰富。我后续的做法是写了一个timer_interrupt_clear_seq这个sequence会等待中断拉高然后执行一次TISR写清除再检查中断是否被清掉。随后把它作为公共片段嵌入到多个随机测试里覆盖率才补上来。覆盖率空洞往往不是环境本身缺什么而是sequence的组合设计缺了实际使用场景。5.5 调试技巧打印和波形配合比单看log高效得多最后分享几个调试技巧都是我在这个项目里实测有效的。第一UVM的报告机制里把UVM_HIGH打印级别只开到需要调试的组件上而不是全环境拉满否则日志会被burst的APB事务刷屏。第二关键状态转移比如参考模型的中断拉高、清除用uvm_info打出来带时间戳这样和波形对照时能精确到纳秒级定位偏差。第三波形文件建议只dump你关心的信号组比如APB总线、中断引脚、寄存器读写事件别整个testbench全dump否则仿真速度和波形文件大小都会成为问题。调试定位问题的时候我通常是先看日志里的uvm_info时间戳锁定大致时间段再打开波形看具体信号时序。日志负责“什么时候发生了什么”波形负责“为什么发生”两者结合定位速度比单看波形快很多。我个人在这套APB Timer验证环境上反复改过三轮第一轮是照着协议文档硬写第二轮是理解了Timer行为后才把参考模型和计分板写对第三轮是补充覆盖率和组合sequence才让环境真正有完备性。中间最大的体会是验证环境的核心不是UVM组件的搬运而是对DUT行为的精确建模参考模型写偏一丁点后面的计分板、覆盖率、testcase全都会跟着歪。如果你也在搭类似的外设验证环境建议多花时间在行为建模和时基对齐上这部分想通了环境搭建的技术活其实就完成了一大半。这套APB Timer环境后续还可以往多个方向扩展比如挂上寄存器模型做后门访问、增加低功耗场景的验证、或者把多个Timer实例化进同一个环境做交错中断测试都是不错的进阶练习方向。