关联数组在 SystemVerilog 里算不上什么新鲜语法,但真到写验证环境的时候,它出现的频率高得离谱——寄存器模型的地址映射、覆盖率 bin 的命中计数、scoreboard 里按 transaction id 归档的数据、参考模型里按地址索引的存储,几乎都是它。IEEE_SV标准里 7.9 这一节把关联数组的声明、遍历和方法定义得挺清楚,可真正动手写代码的时候,坑往往不在语法本身,而在于方法的返回值语义、遍历顺序,以及"读一个不存在的键"到底会发生什么。我这篇东西就是把 7.9 里那批方法按实际使用频次重新排了一遍,配上能直接跑的代码和这几年踩过的坑,给正在写SystemVerilog环境、写参考模型,或者刚啃 SV 的朋友一个能对着抄的版本。文中所有代码都是 unpacked 场景下可综合仿真器直接跑的写法,涉及参数和行为的说明我会标明是标准定义还是我的实测体感,方便你自己判断。
1. 关联数组到底解决什么问题:从场景反推选型
1.1 四种数组的边界在哪里
很多人学 SV 的时候是顺着语法树往下背的,定长数组、动态数组、队列、关联数组一路记下来,结果真到选型的时候还是凭感觉。我的建议是先记两条硬差别:索引能不能自己定,元素能不能单独删。把这两条想清楚,选型基本就不会错。
| 类型 | 声明写法 | 索引范围 | 能否单独删元素 | 典型场景 |
|---|---|---|---|---|
| 定长数组 | int a[8] | 0 到 7,编译期定死 | 不能 | 固定宽度 buffer、小型查表 |
| 动态数组 | int a[] | 0 到 size-1 | 只能整体 delete | 大小事后才知道的列表 |
| 队列 | int q[$] | 0 到 $ | 能,q.delete(i) | FIFO、缓存、临时容器 |
| 关联数组 | int a[string] | 任意整型 / packed 类型 / string / 枚举 /* | 能,a.delete(k) | 稀疏地址映射、按名字索引 |
这张表里最容易被忽略的是最后一列的"稀疏"两个字。定长数组和动态数组的前提是"索引是连续的、从 0 开始的整数",队列在此基础上允许两端增删,而关联数组干脆放弃连续性,换来的是"索引随便定"。放弃连续性的代价也很实在:元素在内存里不再挨着,遍历不再是简单的指针加一,你得靠first/next或者foreach走。
我第一次真正意识到这个差别,是在给一个 32 位地址空间建存储模型的时候。当时顺手写了logic [7:0] mem[logic [31:0]]——这就是个典型的关联数组——如果换成定长数组logic [7:0] mem[1<<32],光地址维度就要 16GB,机器当场就没了。后来我特意算过一笔账:真正常用的寄存器地址也就几百个,加上 debug 空间撑死几千个,关联数组存下来几十 KB,而哈希查找的平均复杂度是 O(1),跟定长数组的直接寻址在体感上没区别。
1.2 稀疏存储与哈希查找的代价拆解
好处说完了,代价必须讲清楚,不然很容易过度使用。关联数组每个条目都有额外的哈希表开销(桶、链表或者开放寻址的探测位),所以在元素很少的时候,它比同样大小的定长数组更占内存、也更慢。我实测过一个很直接的对比:一个只有 8 个条目的int关联数组,和同样 8 个元素的定长数组,前者的内存占用大概是后者的三倍还多,单次查找也要多花几十纳秒。所以在"索引固定、数量很小"的场景里,定长数组仍然是更优解,别为了显得高级就上关联数组。
第二个代价是遍历顺序没有硬保证。这里要说清楚一个容易混淆的点:关联数组的索引本身是有序的(整数按数值序、字符串按字典序),first/last/next/prev这一组方法就是靠这个序来工作的。但foreach的遍历顺序,标准里没有硬性保证,不同仿真器、甚至同一仿真器不同版本上的表现都可能不一样。所以我给自己定死了一条规矩:跟打印、报告、结果比对相关的遍历,一律不依赖 foreach 的顺序,要么用first/next显式按序走,要么把键抽出来排序后再走。
第三个代价跟键的类型有关。字符串键的哈希计算比整型键贵得多,键越长越贵。我做过一个不算严谨但很有参考价值的测试:一百万个条目、键长 12 个字符的字符串关联数组,连续做一百万次查找,耗时大约是同样条目数、用bit [31:0]做键的版本的六到八倍。这个差异在回归里会被放大成很可观的仿真时间。所以如果某个键本质上是地址或者 id,请直接用它本身当键,别用$sformatf拼成一个字符串再当键——除非你真的需要按名字查表,那另说。
1.3 声明方式与索引类型的选择
关联数组的声明语法有个很容易踩的地方:它跟定长数组、动态数组、队列长得太像了。int a[string]是关联数组,int a[10]是定长数组,int a[]是动态数组,int a[$]是队列。我在 code review 的时候真见过有人把[$]看成[],一个队列声明就这么溜过去了,跑到后面行为不对才回头改。
// 最常见的几种声明 int addr_map [bit [31:0]]; // 地址映射,最常用 string cfg [string]; // 按名字索引的配置表 logic [7:0] mem [*]; // 通配符索引,慎用 int cnt [int]; // 整型索引,最省 typedef enum {RD, WR} op_e; int op_cnt [op_e]; // 枚举索引,可读性好 // 也可以用赋值模式一次性初始化(1800-2012 起支持) int regs [string] = '{"ctrl": 32'h1, "stat": 32'h2};索引类型怎么选,我的优先级是:枚举 > packed 整型 > 整型 > string > 通配符。枚举和 packed 整型在内部都是位向量比较,最快,而且打印的时候类型信息明确;string 用来做人类可读的名字索引,代价是哈希计算和大小写敏感;[*]通配符索引看起来最灵活,能把[*]当成"什么类型都行",但它的exists和读操作语义特别容易让人误解——用通配符索引做读操作时,如果匹配到多个元素,拿到哪个或者是不是报错,各家处理不一致,我从来不在需要精确行为的代码里用它。它唯一让我觉得值的场景,是配合exists做批量匹配判断。
2. 关联数组核心方法逐个体检
2.1 num()、size() 与 exists():最容易混的三兄弟
num()返回条目数,size()对关联数组返回同样的值,后者是 SV 后来统一数组接口时加进来的名字,两者在关联数组上等价。老代码、老工具里num()更常见,新写的代码我一般用size(),因为它在动态数组、队列上写法一致,看起来更统一。注意关联数组的"下标范围"概念是不存在的,别指望有类似low()/high()这种东西。
真正要小心的是exists()。它只认精确匹配的键,返回 1 位的结果。这里有个我被坑过的典型写法:
// 反例:想判断"有没有这个键" if (regs["ctrl"] != 0) begin // ... end问题在于,读一个不存在的键,关联数组会返回元素类型的默认值(int是 0,string是空串,logic是 x 或 0 视情况),而不是报错,也不会创建条目。所以如果某个寄存器本来就是全 0,这段代码就把"键存在且值为 0"和"键根本不存在"混成一种情况了。正确写法只有一个:
if (regs.exists("ctrl")) begin // 键存在,再去读值 end顺便说一个很多人的直觉误区:在其他脚本语言里,读一个不存在的键有时会顺手创建条目(自动扩展),但 SystemVerilog 的关联数组不是这样。我在不同仿真器上都验证过,读不存在键不会改变num()的结果。但这个行为太容易被误传,所以你写完带读操作的代码,最好自己用一小段脚本确认一下:
int regs[string]; int before, after, v; regs["ctrl"] = 32'h1; before = regs.num(); v = regs["nope"]; // 读一个不存在的键 after = regs.num(); $display("before=%0d after=%0d v=%0d", before, after, v); // 预期:before=1 after=1 v=02.2 first、last、next、prev:四个遍历指针的返回值语义
这四个方法是按序遍历关联数组的唯一正规手段,返回值必须记牢:能给出索引就返回 1,给不出就返回 0。
| 方法 | 原型 | 作用 | 返回 0 的条件 |
|---|---|---|---|
first | function int first(ref index) | 把 index 设为最小索引 | 数组为空 |
last | function int last(ref index) | 把 index 设为最大索引 | 数组为空 |
next | function int next(ref index) | 把 index 设为比当前大的最小索引 | 已是最大索引或数组为空 |
prev | function int prev(ref index) | 把 index 设为比当前小的最大索引 | 已是最小索引或数组为空 |
排序规则跟索引类型绑定:整型和 packed 类型按数值大小比,字符串按字典序比。字符串的字典序有两个坑必须记住:一是大小写敏感,大写字母排在小写字母前面;二是不是自然数序,字符串"10"比"9"小,因为按字符逐位比较时'1'小于'9'。我见过有人用key存序号字符串然后指望它按数字大小排序,结果打印出来的顺序是 1、10、11、2、3,排查了半天。
还有一个细节要注意:当next/prev返回 0 时,index里剩下来的内容我不去猜。标准里对这种情况的描述藏得比较深,不同仿真器实际表现我确实见过不一致(有的保留原值,有的塞进一个无效值)。所以我的习惯是拿到 0 之后立刻重新first()播种,绝不拿一个"已经被 next 折腾过"的变量去开始新一轮遍历:
string k; // 第一轮遍历结束后 k 已经不可信了 if (regs.first(k)) begin do begin // ... 处理 regs[k] end while (regs.next(k)); end // 第二轮:重新 first() 播种,不要直接接着用 k if (regs.first(k)) begin do begin // ... end while (regs.next(k)); end2.3 delete() 的两种形态与清空陷阱
delete在关联数组上有两种用法,参数给不给,行为完全不同:
regs.delete("ctrl"):删掉单个条目。键不存在时静默返回,不报错,所以如果你写错了键名,不会有任何提示,只会发现数据"莫名其妙少了一块"。regs.delete():清空全部条目。注意这里没有参数,跟队列的q.delete()(也是全清)和q.delete(i)(删单个并前移)语义要注意区分——关联数组删单个元素不会引起任何索引移动,因为索引本来就不连续。
清空整个关联数组,我一般就用regs.delete()。也有人喜欢重新赋一个空模式regs = '{},效果差不多,但可读性不如 delete 直观。还有一种做法是重新 new 一个对象,那属于类成员重建的范畴,就不在这节讨论了。
这里插一条实测出来的经验:如果目标是整个换掉内容,别逐个 delete 再逐个填。我曾经在一个环境里写过"清空 5000 条再填 5000 条"的逻辑,跑完一轮下来比直接delete()再整体赋值慢了差不多三分之一。原因不复杂,逐个删会反复触发哈希表的收缩和重整,整体清空是一次性操作。所以我现在的写法是:
regs.delete(); // 一次性清干净 foreach (new_data[i]) begin regs[new_data[i].name] = new_data[i].value; end2.4 方法速查表与配套的数组操作方法
把 7.9 这一节的方法和配套的数组操作方法放一起,做一张能贴在手边的表。为什么要把 7.12 那一节的数组操作方法也放进来?因为在真实代码里它们是混着用的——遍历拿键、排序、找最大值这些需求会同时出现在同一个函数里。
| 方法 | 用在哪 | 返回值 | 我的使用频率 |
|---|---|---|---|
num()/size() | 关联数组 | 条目数 | 极高 |
exists(index) | 关联数组 | 1 位,存在为 1 | 极高 |
delete(index)/delete() | 关联数组 | 无 | 极高 |
first(ref i)/next(ref i) | 关联数组 | 1 位 | 高 |
last(ref i)/prev(ref i) | 关联数组 | 1 位 | 中,反向遍历时用 |
foreach (aa[i]) | 所有数组 | 无 | 极高 |
find() with (expr) | 所有数组 | 元素队列 | 中 |
find_index() with (expr) | 所有数组 | 索引队列 | 中 |
min()/max() | 所有数组 | 元素 | 低 |
unique()/unique_index() | 所有数组 | 队列 | 低 |
sum()/and()/or()/xor() | 所有数组 | 归约结果 | 低 |
sort()/rsort()/reverse()/shuffle() | 动态数组、队列 | 无 | 中,配合队列用 |
有一件事要单独说:关联数组不支持直接用==比较。你想判断两个关联数组内容是否一致,没有现成运算符,得自己手写循环按键比对,或者干脆在写的时候就避免产生"两个需要比对的关联数组"。我在 scoreboard 里就吃过这个亏,最后是封装了一个compare_aa函数,逐键exists加值比较,顺便还能把第一个不一致的键名打出来,比一个笼统的"不相等"有用得多。
3. 遍历、排序与批量操作实操
3.1 用 do-while 加 next 写出稳的遍历骨架
按序遍历关联数组,我固定用这个骨架,几乎是个肌肉记忆:
function void dump_regs(); string key; if (regs.first(key)) begin // 空数组直接跳出去 do begin $display(" %-12s = 0x%08h", key, regs[key]); end while (regs.next(key)); // 返回 0 就结束 end endfunction为什么是do-while而不是while?如果用while (regs.next(key))开头,你得先手工调用一次first(key)把初始值放进去,很容易漏掉导致第一个元素被跳过或者整个循环不进。do-while的结构天然保证"先用当前 key,再往后走"。但do-while有个反直觉的坑:它是先执行循环体再判断条件,所以如果数组是空的,first(key)返回 0,而do体还是会老老实实执行一次,用一堆未初始化的 key 去做查找。这就是为什么外面必须套一层if (regs.first(key)),这一层不是为了美观,是功能正确性的一部分,千万别为了省一行删掉。
第三个小细节是打印格式里的%-12s。用固定宽度对齐输出,排查问题时一眼能看出哪个键的值不对,这比省几个字符有意义得多。我在环境里所有关联数组的 dump 函数都保持这个习惯。
3.2 把键抽出来排序:为什么要绕道队列
关联数组是无序容器,"给关联数组排序"这个说法本身就不严谨——你排的是键还是值?排完之后索引和值的对应关系怎么变?标准里的排序方法主要面向动态数组和队列,作用在关联数组上时语义容易让人误解。所以我从来不在关联数组上直接调排序,而是走一条更清楚的路:把键抽到队列里,再排队列。
string keys[$]; string k; // 方法一:用 find_index 一把捞出来 keys = regs.find_index() with (1); // 方法二:手写遍历,行为最可控 keys.delete(); if (regs.first(k)) begin do begin keys.push_back(k); end while (regs.next(k)); end keys.sort(); // 队列排序,默认升序 foreach (keys[i]) begin $display("[%0d] %-12s = 0x%08h", i, keys[i], regs[keys[i]]); end这两种写法我都会用,取决于场景。如果是临时调试脚本,find_index()一行最省事;如果是正式环境里的代码,我倾向手写遍历,因为它的行为我完全清楚,不依赖对with里item到底指值还是指索引的记忆。
顺带说一个很实际的判断:with表达式里的item具体指向什么,在find、find_index、min、max这些方法之间说法不完全一致,很容易记混。我的做法是——如果一段代码要靠我回忆文档才能确认语义,那它就不该出现在环境里。要么改成不带with的写法,要么手写遍历,多写三行换来不用每次 review 都心里打鼓,这笔买卖很划算。
3.3 批量删除:先收键,再动手
遍历过程中修改容器,是所有语言里的经典雷区,SystemVerilog 也不例外。next依赖当前索引去找"下一个更大的索引",如果你在循环体里删掉了当前条目,这个"下一个"就可能被跳过或者直接失效。我踩过一次,删完之后发现有两三个条目莫名其妙活了下来,排查了很久。
稳妥的做法是分两步:第一轮只收集待删的键,第二轮统一删。
string victims[$]; string k; // 第一轮:只读,收集 if (cfg.first(k)) begin do begin if (k.substr(0, 3) == "tmp_") begin victims.push_back(k); end end while (cfg.next(k)); end // 第二轮:只删 foreach (victims[i]) begin cfg.delete(victims[i]); end victims.delete(); // 收尾清干净,避免影响下次调用注意最后那句victims.delete()。队列如果声明在函数内部本来无所谓,但如果是类成员变量,忘了清会导致下一轮误删上一轮残留的键,这类 bug 特别隐蔽,因为症状是"删多了",而不是"没删掉"。另外,如果待删的比例很高(比如超过一半),其实不如直接cfg.delete()清空然后把保留的键重新填回去,代码更短,速度也更快。
3.4 find / unique / 归约方法什么时候值得用
locator 方法不是不能用,而是要知道它擅长什么。我总结下来,find系列最值的情况是"我只要值,不要键",比如把某个字段全捞出来做统计:
int unsigned total; total = regs.sum() with (item); // 所有寄存器值求和 int vals[$]; vals = regs.find() with (item > 32'h1000); // 值大于阈值的元素 int max_val; max_val = regs.max(); // 最大值这几个写法确实简洁。但要记住两条边界:一是find返回的是值的队列,不是键的队列,如果你后面还要按键去改原数组,那得用find_index;二是unique/unique_index这类方法在处理大数组时开销不低,我在百万级条目上试过,一次unique()就够呛,那种规模更应该从数据源头去重,而不是事后扫一遍。
4. 常见问题与排查技巧实录
4.1 用值判断存在性:最隐蔽的一类 bug
这是我在团队里见过最多的一个坑,而且因为症状是"偶尔算错"而不是"直接崩",特别难忘。典型代码长这样:
// 反例 int hit_count[string]; // ... 某个地方读 if (hit_count[bin_name]) begin hit_count[bin_name]++; endhit_count[bin_name]在键不存在时返回默认值 0,而 0 恰好是"计数为 0"的合法值,两种情况完全无法区分。这段代码的结果是:键不存在时永远进不去 if,计数永远累加不起来。正确写法只有一个:
// 正例 if (hit_count.exists(bin_name)) begin hit_count[bin_name]++; end else begin hit_count[bin_name] = 1; end或者更简洁一点,直接利用"读不存在的键返回默认值、写会创建条目"这两个特性:
hit_count[bin_name] = hit_count[bin_name] + 1;这一行等价于上面那段分支,而且不会因为漏写 exists 而出错。但要明白它为什么成立:读的那一步拿到默认值 0,写的那一步创建条目,两步合起来就是"不存在当 0,存在就加一"。这个写法我在覆盖率统计里用了很多年,前提是你确定默认值 0 就是你要的语义。如果元素类型是string或者类句柄,默认值是空串或 null,那就必须老老实实写exists。
4.2 遍历指针失效的几种典型触发
除了前面说的遍历中删除,还有几种让遍历指针失效的操作,列出来对照检查:
- 在
foreach循环体里 push 或 delete 条目,遍历顺序和覆盖范围都不再可靠; - 在
do-while里改了作为索引的那个变量,比如手滑写了个key = key + "x",下一轮next(key)就完全跑偏; - 循环中途
break出来,后面接着复用同一个索引变量开始新遍历,没重新first()播种; - 在嵌套函数里对同一个关联数组做遍历,外层内层互相干扰。
排查这类问题有个很土但很有效的办法:在遍历开始和结束各打一次num(),两个值一样才说明遍历期间数组没被改过。如果不一样,那就是有别的代码路径在动它,顺着delete和赋值语句去 grep 就行。
4.3 字符串键的格式统一
只要用字符串当键,就会遇到格式问题。我吃过的最典型的一次是:一边写regs[$sformatf("0x%0h", addr)],另一边查的时候写regs[$sformatf("%0h", addr)],一个有0x前缀一个没有,两边的键永远不会相等。这类问题的排查成本极高,因为两边单独看都很"正确"。
我给自己定的规矩是:键的生成只有一个入口。环境里封装一个get_key(addr)函数,所有读写都走它,绝不允许在不同模块里手写格式化字符串。另外,十六进制统一用小写、固定宽度(比如%08h)、不要带前缀。固定宽度不只是为了对齐好看——它保证了字符串的字典序和数值序一致,前面说的"10"小于"9"的问题也就顺手解决了。
4.4 传参拷贝与 ref 的性能差异
关联数组作为函数参数时,默认是按值传递,也就是整份拷贝一份。十万条目的关联数组,一次函数调用就是一次十万条的全量复制。我在一个环境里把 dump 函数写成function void dump(int regs[string]),一轮回归跑下来慢了十几分钟才反应过来是这里的问题。
class reg_env; int regs[string]; // 反例:全量拷贝 function void dump_bad(int r[string]); // ... endfunction // 正例:引用传递 function void dump_good(ref int r[string]); // ... endfunction // 更好的做法:直接访问成员,参数都不要 function void dump_member(); // 直接用 this.regs endfunction endclass我的实际建议是:类内部的 dump、统计这类函数,直接访问成员变量,不要传参,最省事也最快;确实需要传参的跨模块接口,参数上一定要加ref。另外提一句,模块端口上直接挂关联数组这件事我不推荐,工具支持度参差不齐,而且端口本来就是跨模块通信的边界,用一个类包起来传更清楚。
4.5 问题速查表
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 计数总是加不上去 | 用值判断存在性,默认值 0 混进来了 | 换成exists()或直接的读改写 |
| 遍历漏掉几个条目 | 循环体里删了元素 | 改成先收键再删的两段式 |
| 打印顺序每次不一样 | 依赖了foreach的顺序 | 抽出键到队列sort()后遍历 |
| 查找总是找不到 | 键的格式不一致(前缀、大小写、宽度) | 统一键生成入口,打印键的实际内容 |
| 仿真越跑越慢 | 大关联数组按值传参,或字符串键过长 | 参数加ref,键换成 packed 整型 |
| 第二轮遍历结果不对 | 索引变量没重新first()播种 | 每轮遍历前重新播种 |
| 删完之后还剩几条 | 遍历中删除破坏了遍历指针 | 两段式,并检查 victims 队列是否清空 |
5. 两个真实落地场景
5.1 用关联数组做寄存器地址映射表
场景很简单:一个模块有几百个寄存器,我要支持"按地址查到名字"和"按名字查到地址"两个方向的查询。用两个关联数组互相映射,是最省事的做法。
class reg_map; typedef bit [31:0] addr_t; string name_by_addr [addr_t]; // 地址 -> 名字 addr_t addr_by_name [string]; // 名字 -> 地址 function void add(addr_t addr, string name); // 冲突检查一定要做,重复注册是这类表最常见的事故 if (name_by_addr.exists(addr)) begin $error("addr 0x%08h already mapped to %s", addr, name_by_addr[addr]); return; end if (addr_by_name.exists(name)) begin $error("name %s already mapped to 0x%08h", name, addr_by_name[name]); return; end name_by_addr[addr] = name; addr_by_name[name] = addr; endfunction function string lookup_name(addr_t addr); if (!name_by_addr.exists(addr)) return "UNKNOWN"; return name_by_addr[addr]; endfunction function void dump_sorted(); addr_t keys[$]; addr_t k; if (name_by_addr.first(k)) begin do begin keys.push_back(k); end while (name_by_addr.next(k)); end keys.sort(); // packed 类型排序按数值 foreach (keys[i]) begin $display(" 0x%08h %s", keys[i], name_by_addr[keys[i]]); end endfunction endclass这里有几处是踩过坑才加上的。第一处是add里的双向冲突检查,一开始我只查了一个方向,结果两个不同名字注册到同一个地址,第二个把第一个覆盖掉了,dump 出来少了一个寄存器,查了很久。第二处是dump_sorted里为什么不用foreach直接打——因为打印出来的顺序会变,跟 DUT 文档对不上,review 的人第一反应就是"你是不是少了一个"。抽键排序这十几行代码,省下的是后面无数次的解释成本。第三处是地址键用bit [31:0]而不是字符串,前面算过,查找速度差出好几倍。
5.2 用关联数组统计覆盖率 bin 的命中次数
第二个场景是功能覆盖率之外的补充统计。有些覆盖点不好用covergroup表达,比如"某个状态机在异常路径下访问过的寄存器集合",用关联数组计数反而更灵活。
class bin_tracker; int unsigned hit_count [string]; int unsigned total_cycles; function void sample(string bin_name, int unsigned value); total_cycles++; hit_count[bin_name] = hit_count[bin_name] + 1; if (value > 32'hFFFF) begin string key = {bin_name, "_overflow"}; // 派生键,注意命名唯一 hit_count[key] = hit_count[key] + 1; end endfunction function void report(); string k; int unsigned hit, miss; miss = 0; if (hit_count.first(k)) begin do begin hit = hit_count[k]; if (hit == 0) miss++; $display(" %-24s : %0d", k, hit); end while (hit_count.next(k)); end $display("bins=%0d uncovered=%0d cycles=%0d", hit_count.num(), miss, total_cycles); endfunction endclass这段代码里hit_count[bin_name] = hit_count[bin_name] + 1就是 4.1 节说的那个"读-改-写"写法,利用默认值 0 做到不存在即从 1 开始。report里那句if (hit == 0) miss++;看着有点多余——既然计数从来都是从 1 开始的,怎么会有 0?但实际情况是,有些 bin 是在别的地方预先注册进去的(用hit_count[name] = 0占位),那样就会出现 0 值。这个细节是我在做覆盖率报告的时候发现的:报告里显示的"未覆盖数"跟预期差了几个,追下去才发现是预注册和计数两拨逻辑对不上。所以我现在的做法是,要么全部靠采样自动创建,要么全部预注册,绝不混用。
5.3 关于性能的几个实测体感
最后把我自己在性能上的一些体感摊开说,都不是严谨基准测试,但方向性参考价值还是有的。
第一,遍历方式。LRM 里其实提示过next()的性能不一定好,因为要按索引序找下一个,实现上可能每次都要做一次哈希查找。实测下来确实如此:十万条目的遍历,foreach一轮的开销明显低于first/next一轮。所以我给自己定的规矩是:只遍历不关心顺序,用foreach;需要按序或者需要索引值,用first/next。两者不是谁替代谁的关系。
第二,键的类型。前面算过,字符串键的查找比整型键慢好几倍,键越长差异越明显。如果键是从地址或者 id 拼出来的,先把"我到底需不需要字符串"这件事想清楚,很多场景只是为了让日志好看一点,那完全可以在打印的那一刻再转成字符串。
第三,清空和重建的成本。一次性delete()比逐个删快,这点前面说过;另外,如果你的关联数组每轮测试都要重建,考虑用ref传参而不是反复 new 对象,对象构造和析构在数据量大的时候也是有成本的。
第四,元素类型的大小。关联数组存的是元素本身,如果你往里塞很大的 struct,内存和拷贝成本都会上去。这种情况我会改存句柄,用关联数组做"索引到对象"的映射,这也是 UVM 里uvm_reg_map那类结构常见的做法。