☰
SystemVerilog随机化:rand与randc机制及用constraint实现无重复随机
2026/10/3 5:08:03 网站建设 项目流程

几个月前帮组里一位刚转验证的同事Review代码,看到一个激励类里写着这样一行:

randc bit [7:0] addr;

同事很自信地跟我解释:“加了c就是更随机一点,不会连续出现同样的地址。”听起来好像没什么毛病,但后来回归跑了整整一周,地址在全覆盖率的统计里始终有两个值没有被踩到。问题就出在他对randc的理解上:他把randc当成了“加强版rand”,却不知道randc背后有一套完整的轮次调度机制。类似的误会我在代码评审里见过太多次了,所以这篇直接把rand和randc拆开讲透,包括它们各自的行为差异、底层调度逻辑,以及标题里另一个问题——如果项目里不允许用randc,怎么用rand加constraint实现同样“一轮内不重复”的效果。

这篇文章适合三类人看:刚入行芯片验证、正在啃SystemVerilog随机化约束的工程师;在UVM环境中写sequence、写寄存器模型但被randc坑过的人;以及做覆盖率优化时想通过随机化提高遍历效率的验证同学。

1. 骰子与牌堆:rand和randc在“随机”这件事上的本质分歧

先抛结论:rand是“骰子”,每次投掷完全独立,跟之前出现过什么没有关系;randc是“牌堆”,一副牌洗完发完才算一轮,发过的牌不会重复出现,直到整副牌发完才重新洗牌。

很多教材会把randc解释成“循环随机”,这个说法没有错,但很容易被误解成“它只是更容易均匀一点”。实际上randc的行为是严格的不放回抽样:对于一个N比特的randc变量,它的取值范围是2的N次方个离散值,随机化求解器会从这2的N次方个值里每次抽一个,抽过的值从本轮候选集合中剔除,直到所有值都被抽过一遍,下一轮才重新开始。而rand在每次调用randomize()时都是从全集里重新独立抽样,允许连续两次抽到完全相同的值。

1.1 一次16次随机的对照实验

口说无凭。我们在仿真里跑一个小例子:

class rand_vs_randc; rand bit [7:0] r1; randc bit [7:0] r2; endclass module tb; initial begin rand_vs_randc obj = new; repeat (16) begin void'(obj.randomize()); $display("rand = %0d, randc = %0d", obj.r1, obj.r2); end end endmodule

这段代码里r1和r2每次一起随机化。r1是普通rand,16次打印中你大概率会看到有人重复(虽然8位范围下重复概率不算高,但理论上是允许且会发生的);而r2因为是randc,16次打印里必然不存在重复值,因为一轮256个值远没有发完。

这个实验对验证工程师的核心启发是:如果你需要依靠随机化去踩遍一个较大的合法地址空间或命令码集合,用randc会比rand更高效;如果你只是想模拟真实业务流中独立且可能重复的事件,rand反而更贴近物理世界。

1.2 两者的适用边界和选择原则

我把两个关键字的行为差异整理成一张对照表,方便以后写代码前直接拍板:

对比维度randrandc
每次randomize行为独立抽样,可能重复本轮内不放回,不能重复
全值域遍历能力无保证,靠大样本逼近有保证,一轮内覆盖全部取值范围
是否存在隐藏状态无,无记忆性有,每个实例维护轮次进度
一轮周期长度不适用满量程时为2的N次方
与constraint结合的稳定性稳定,业界主用受约束影响,候选集合可能变化
典型使用场景大批量随机激励、独立事件模拟枚举遍历、地址全覆盖、命令码轮询

选择原则其实就一条:看你想模拟的现象是否要求“近期不重样”。比如要随机产生一个寄存器地址去反复读写,如果0x00和0xFF都是关键边界,你用rand可能要跑很久才能踩到一次边界,用randc则保证每一个值都会出现一轮;反过来,如果模拟总线事务包的长度,你希望每次包长是独立的,那应该用rand而不是randc,因为randc会强行让16个长度的包轮着出现,真实场景反而不像。

2. randc的“轮”是怎么转的:周期、状态与约束边界

理解了randc是牌堆还不够,真正容易出问题的是“这堆牌什么时候重洗、洗完之后跟上一轮是什么关系、跟其他约束叠在一起时还算不算数”。这一章把randc的内部机制和边界条件讲清楚。

SystemVerilog标准规定randc变量的取值范围就是它的满量程,比如randc bit [7:0]就是0到255,一个完整的轮次必然包含这256个值,且本轮内不重复。求解器内部对实现方式没有强制要求——有的仿真器用隐藏计数器,有的用排除列表——但标准只约束行为,这一点你在不同EDA工具之间切换时不需要担心行为不一致,倒是性能会有差异。

2.1 new()、轮次进度与对象生命周期

这是我在面试和代码评审时最喜欢考的一个点:randc的轮次进度是跟对象实例绑定的,不是跟类绑定的,更不是全局的。

每当你对同一个对象连续调用randomize()时,randc才会按轮次推进。一旦你用new()重新创建一个对象,randc的轮次进度就重新初始化,相当于牌堆重新洗好但还没发牌。这个特性对验证环境的设计影响很大。

举个例子,你在sequence里这样写:

class my_seq extends uvm_sequence #(my_trans); virtual task body(); repeat (10) begin my_trans req = my_trans::type_id::create("req"); start_item(req); void'(req.randomize()); finish_item(req); end endtask endclass

每次循环都create一个新对象,每个新对象只randomize()一次就丢掉了。从全局看,每个对象的randc都只走了轮次的第一个位置,随机化求解器根据种子为每个新对象生成一个“轮次初始值”,你这个sequence虽然调用了10次随机化,却可能产生大量重复值,某些值永远轮不到。这就是开头那个同事遇到的“回归跑一周还有地址没踩到”的典型成因。

正确做法是让随机化对象跨多次调用存活:

class my_seq extends uvm_sequence #(my_trans); virtual task body(); my_trans req = my_trans::type_id::create("req"); repeat (10) begin start_item(req); void'(req.randomize()); finish_item(req); end endtask endclass

把create挪到循环外面,同一个对象被随机化10次,randc才能按轮次推进。这个小改动往往能显著改善地址类激励的全覆盖收敛速度。

2.2 和constraint并存的隐藏代价

randc不是孤立工作的,它几乎总会跟constraint一起出现。最经典的写法是:

randc bit [7:0] addr; constraint c_addr_valid { addr inside {[0:9]}; }

这行代码的意思是:候选集合从0到255被约束成了0到9,那么一轮周期就从256次变成了10次,且这10次里0到9每个值恰好出现一次。这种“集合型约束”(inside、关系运算符、算术表达式构造出的合法集合)下,randc的轮次行为在主流仿真器里是基本可靠的。

但有一种情况必须警惕:当randc变量和别的rand变量之间有交叉约束时,randc的“遍历承诺”很可能被打破。比如:

randc bit [2:0] cmd; rand bit [2:0] data; constraint c_cross { cmd > data; }

这个约束把cmd和data绑定在了一起。每轮求解时,求解器不仅要满足cmd轮内不重复,还要满足cmd比当前data大。当一个候选cmd值跟本轮其它约束冲突时,求解器完全有可能跳过它,这一轮结束后你会发现某个cmd值从头到尾没出现过。也就是说,只要约束不是单纯地对randc变量划定一个静态合法集合,而是牵扯到其它随机变量或状态,你就不能依赖randc做严格的全遍历。

我的建议是:让randc变量只跟“静态集合型约束”搭配,凡是涉及多个随机变量联动的约束,要么改成简单rand然后靠概率覆盖,要么干脆用第3章的手写方案,把“轮次”主动权拿在自己手里。

3. 不用randc,如何在constraint里再造一个“无重复循环”

标题里那个问题“如何用constraint实现randc”,在很多项目里是真实需求。原因五花八门:团队代码规范要求所有随机成员必须是rand,工具对randc的求解性能太差,或者你需要在一个超大值域内做无重复抽样但不想让求解器去维护一个巨型候选集。

这里给两个可行方案,一个是通用但适用小值域的Used-Queue回填法,一个是大值域低内存的LCG步长游走法。

3.1 方案一:Used-Queue回填法(推荐小值域)

核心思想:用一个队列记录本轮已经产生过的值,约束中要求新值不能出现在这个队列中;当队列长度达到值域规模时,说明这一轮已经遍历完,下一轮前清空队列、重新开始。

class randc_by_constraint; parameter int unsigned N = 16; rand bit [3:0] val; local bit [3:0] used_q[$]; local int unsigned used_cnt = 0; constraint c_randc_sim { if (used_cnt < N) { !(val inside {used_q}); } } function void post_randomize(); if (used_cnt >= N) begin used_q.delete(); used_cnt = 0; end used_q.push_back(val); used_cnt++; endfunction endclass

逐行解释一下这个实现的心脏部分:

  • 约束!(val inside {used_q})是主角。inside后面的队列里存的是本轮已经出现过的值,!取反后,新val必然不在其中。
  • used_cnt < N作为轮次判断条件。前N-1次随机化时,count一直小于N,约束被激活,不断排除旧值;第N次随机化时,count等于N-1,used_q里有N-1个值,val只能选最后一个从未出现的值。
  • 等到第N次随机化结束,post_randomize里会先把queue清空、count归零,再记录当前值。这样下一轮又从空集开始,行为跟randc完全一致。

这个方案的好处是逻辑直观、行为完全可控,跨轮边界也跟randc一样允许上一轮的最后一个值和本轮第一个值相同(因为清空后无约束)。缺点同样明显:约束里每多一个已用值,inside表达式就会多一个成员,求解器要把它们全部展开成排除条件。当N超过几千甚至上万时,求解时间会迅速恶化。所以我的建议是:值域规模在几百以内时放心用这个方案,值域超过四位数就要认真考虑性能问题了。

3.2 方案二:LCG步长游走法(适合大值域低内存)

如果值域很大,比如bit [31:0],你不可能用一个队列去存4G个已用值。这时候可以用线性同余发生器(LCG)的思路,在constraint之外手动维护一个步长和当前位置,通过数学性质保证一轮内无重复。

LCG能实现无重复遍历的原理很简单:对于值域N,只要步长step和N互质,那么从任意起点开始,不断执行(idx + step) % N,会在回到起点前恰好走遍N个值,一个不多一个不少。这样我们就不需要记录任何历史值,只需要在每一轮结束时重新随机选一个与N互质的步长,让下一轮的顺序发生变化。

class lcg_randc_sim; local int unsigned N; local int unsigned idx; local int unsigned step; function new(int unsigned n); N = n; idx = 0; step = 1; endfunction function int unsigned next(); int unsigned v; v = idx; idx = (idx + step) % N; if (idx == 0) begin do begin step = $urandom_range(1, N - 1); end while (gcd(step, N) != 1); end return v; endfunction function int unsigned gcd(int unsigned a, int unsigned b); while (b != 0) begin int unsigned t = b; b = a % b; a = t; end return a; endfunction endclass

这段代码里有一个细节值得说明:do...while重新选step的时机是idx == 0,也就是步长把这一轮完整走完、准备开启下一轮的时刻。gcd(step, N) != 1的循环确保新步长与值域互质,这是LCG周期等于N的前提条件。如果N是2的幂,gcd判断可以简化成“step只取奇数”,速度更快。

这个方案的优点非常突出:内存占用是常数级,不管值域是8位还是32位,都只存三个整数。缺点是它产生的不是完全随机的排列,而是等差数列上按模走出来的顺序,连续两个值之间的间隔固定为step。如果被测DUT对输入之间的相关性敏感,这种等差排列可能会掩盖某些跨周期交互的bug。所以它更适合“只需要保证不重复、对顺序随机性要求不高”的大范围场景,比如大批量写入不同地址做压力测试。

3.3 两个方案的实测对比与选择建议

我在这两种实现上都跑过一轮覆盖实验,结论很清晰:

方案值域规模建议内存随机性强弱实现复杂度
Used-Queue回填法数百以内随值域线性增长强,接近原生randc低
LCG步长游走法数千到32位全值域常数级中,有等差数列相关性中

实际选型时我的习惯是:先用原生randc;原生randc在复杂约束下行为不可控时,改用Used-Queue回填法;值域超过四位数且约束求解被拖到几秒以上时,才切到LCG方案。

还有一点,不管用哪个手写方案,都建议把“已用集合”或“当前步长”这类内部状态做成local,避免外部代码不小心改坏。这点在team里多人维护同一个验证环境时尤其重要。

4. 实战里那些让randc失效的隐蔽场景

前几章把原理讲透了,这一章全是真实项目里踩过的坑。如果说第2章是“randc本身的行为”,那这一章就是“验证环境的行为如何反过来坑randc”。

4.1 生命周期类问题:new出来的对象和共享对象

前文已经讲过“短生命周期对象会打断randc轮次”。这里再补一个反面场景:多个sequence共享同一个transactor对象。

假设顶层testbench里有一个公共的地址生成器对象,几个sequence并行运行时都对它调用randomize()。两个并行进程同时操作同一个对象的randc状态时,会发生类似“两个人同时从一副牌里抽牌”的竞态:每个进程拿到的值确实不重复,但两个进程会互相消费掉对方的候选值,实际观察到的激励序列可能在一轮还没结束时出现“看起来重复”的情况,因为进程A拿到的值和进程B上一轮结束时拿到的值在全局时间线上可能相邻。

标准没有规定多进程并发对同一对象randc调用的原子性,所以遇到这种场景不要依赖仿真器帮你兜底。我的经验是:要么给生成器加uvm_resource或semaphore做互斥,要么每个sequence持有自己的独立对象,不要让公共对象进入并发随机的射程范围。

4.2 register model上慎用randc

在UVM寄存器模型中,如果你把一个寄存器字段定义成randc:

randc uvm_reg_field some_field;

这个行为很有诱惑力,因为你希望写寄存器时每个合法值都轮一遍。但寄存器模型有大量后门操作、镜像更新、predict操作,这些路径会直接改写字段值,不会走randomize的轮次推进逻辑。一旦寄存器值被外力写成一个不在当前randc候选集合中的值,下一次调用randomize时,求解器既要满足randc的轮次约束,又要保留字段当前值(如果字段不在随机域中或者被约束固定住),容易出现CONSTRAINT INFEASIBLE,或者行为在不同仿真器之间不一致。

我的建议:寄存器模型字段一律用rand配合一个合法的值列表约束,不要用randc。需要遍历合法值的话,用uvm_reg_field::randomize()配合外部计数器或者干脆预生成一个枚举数组,按索引写入,这样日志可追溯,也不会被后门写操作干扰。

4.3 用randc后覆盖率仍然不动的排查思路

有一次我排查一个“randc地址生成器跑了几万次,某些地址还是没覆盖到”的case,第一反应是约束有问题。排查链路大概是这样的:

  • 先检查是否存在第2章说的“交叉约束破坏轮次”,把randc变量和普通rand变量之间的cross约束逐条注释掉,观察覆盖点是否开始前进。这一步能快速定位是不是约束联动导致某些值被求解器跳过。
  • 然后检查对象生命周期,看被随机化的对象是不是每次循环都new。如果是,改成循环外创建再跑一遍,覆盖率往往立刻改善。
  • 再检查是否在randomize() with {}内联约束里额外夹带了其它随机变量。有些仿真器在in-line constraint引入临时随机变量时,会扩大解空间搜索,randc的候选调度可能被干扰。我遇到过一次奇怪的现象,把in-line约束改写为具名constraint后问题消失,之后项目里就定了规矩:涉及randc成员的约束一律用具名constraint,不用with从句。
  • 最后检查代码覆盖率工具的采样方式。如果覆盖率模型对枚举值的bins定义把0到255拆成非法和合法两组,而你的randc被约束在合法组内,某些“合法却不需要关心”的值也可能因为bins自动生成被误判为覆盖缺口。这种是工具配置问题,跟随机化代码无关,别在这上面浪费时间。

整个排查过程最忌讳的是一上来就怀疑EDA工具有bug。99%的情况下,randc失效都是因为约束联动、对象生命周期或者内联约束这三类原因。

4.4 一个更可控的替代思路:预生成排列数组

如果项目对激励顺序的可复现性要求特别高,或者你想彻底绕开randc在整个验证环境里引入的隐性状态,我强烈推荐一个老派做法:在测试平台初始化阶段用$urandom和洗牌算法一次性生成一整轮无重复排列,存到队列里,序列里按索引依次取用。

class perm_gen; local int unsigned arr[$]; local int unsigned idx = 0; function void build(int unsigned n); arr.delete(); for (int unsigned i = 0; i < n; i++) arr.push_back(i); for (int unsigned i = n - 1; i > 0; i--) begin int unsigned j = $urandom_range(i); int unsigned tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; end endfunction function int unsigned next(); if (idx >= arr.size()) begin build(arr.size()); idx = 0; end return arr[idx++]; endfunction endclass

这种实现把所有随机性集中在初始化阶段,之后每一轮都只是按顺序读数组。好处有三个:一是行为完全可预测,打印日志时只要记录当前排列数组的种子就能复现整个序列;二是随机值之间没有隐含的求解器状态,并行环境里更安全;三是可以随时在build函数里对排列结果做后处理,比如要排除非法地址或者做加权排序,比在constraint里绕来绕去容易得多。

代价是内存占用和初始化时间。对百万以下量级的值域来说,这个代价微乎其微,所以我个人在地址遍历、命令码遍历这类场景里越来越倾向于这个方法,原生randc反而成了次选。

最后补一个我自己的习惯。判断一个随机成员到底该用rand还是randc,或者该不该用手写方案,我先问自己三个问题:候选值域规模是多少?这一轮遍历是否允许被其它约束打乱?激励序列是否需要精确复现?把这三个问题想清楚,再回头看代码,大多数随机化设计其实都能直接拍板,不需要纠结关键字本身。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询