资讯动态

SystemVerilog关联数组方法实战:exists、遍历与性能避坑

发布时间:2026/10/2 20:42:12 来源:尧图企业网站定制
关联数组在 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[132]光地址维度就要 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: 32h1, stat: 32h2};索引类型怎么选我的优先级是枚举 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是 0string是空串logic是 x 或 0 视情况而不是报错也不会创建条目。所以如果某个寄存器本来就是全 0这段代码就把键存在且值为 0和键根本不存在混成一种情况了。正确写法只有一个if (regs.exists(ctrl)) begin // 键存在再去读值 end顺便说一个很多人的直觉误区在其他脚本语言里读一个不存在的键有时会顺手创建条目自动扩展但 SystemVerilog 的关联数组不是这样。我在不同仿真器上都验证过读不存在键不会改变num()的结果。但这个行为太容易被误传所以你写完带读操作的代码最好自己用一小段脚本确认一下int regs[string]; int before, after, v; regs[ctrl] 32h1; before regs.num(); v regs[nope]; // 读一个不存在的键 after regs.num(); $display(before%0d after%0d v%0d, before, after, v); // 预期before1 after1 v02.2 first、last、next、prev四个遍历指针的返回值语义这四个方法是按序遍历关联数组的唯一正规手段返回值必须记牢能给出索引就返回 1给不出就返回 0。方法原型作用返回 0 的条件firstfunction int first(ref index)把 index 设为最小索引数组为空lastfunction int last(ref index)把 index 设为最大索引数组为空nextfunction int next(ref index)把 index 设为比当前大的最小索引已是最大索引或数组为空prevfunction 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 32h1000); // 值大于阈值的元素 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 32hFFFF) 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那类结构常见的做法。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑