1. 先搞清楚枚举和字符串各自解决什么问题1.1 一段看着没问题的代码为什么跑飞了前阵子帮 A 同学看一个仿真挂死的 case波形拉出来状态机的状态信号是一串3、5、7日志里刷的是$display(cur state %0d, state)。他跟我说这个 case 跑了三个小时才复现挂的时候只知道状态是 7但 7 到底对应哪个状态代码翻了三遍才对上号。这类问题在纯 Verilog 时代特别常见状态用parameter或者宏定义编译期没有任何约束谁都能往这个信号里塞一个不存在的值等到仿真跑飞了再回头查。另一个方向的问题正好相反。同一个项目里断言失败信息是用一堆{}拼出来的FAIL at addr addr这种写法在 SV 里其实是{FAIL at addr , addr}但有人写成了编译直接报错还有人把字符串塞进logic [255:0]打印出来末尾一堆\0。这两种毛病看起来不搭边本质上都是同一件事没搞清楚这个类型在语言层面到底承诺了什么。枚举承诺的是这个变量只可能是这几个值之一编译期替你挡住非法赋值字符串承诺的是这是一个变长的、按值拷贝的字节序列你不用关心长度和内存。把这两块啃透日常写 RTL 和验证代码时能少掉一大半低级错误。1.2 枚举管状态字符串管人话我给这两者的分工是这么理解的枚举是给机器看的标签字符串是给人看的话。枚举出现在状态机、模式选择、操作码、覆盖率采样点上它的价值在于类型安全和可读性字符串出现在日志、错误信息、配置文件解析、DPI 交互、命令行参数处理上它的价值在于灵活和可读。真正让这两块知识产生交集的地方是它们的互转。枚举的.name()方法直接吐出字符串反过来把配置文本里的RUN解析成枚举成员则是每一个带配置驱动的测试平台都要写的一段胶水代码。这段胶水写得好不好直接决定了后面加一个新状态时你要改几个文件。1.3 这份内容适合谁看如果你刚写完半年 Verilog正在往 SystemVerilog 迁移那这篇基本是必备的如果你已经写了一两年验证代码但枚举的.next()只会在for循环里用、字符串只会$display那这篇能补上不少细节如果你正在搭一个配置驱动的测试环境第 5 章的互转方案可以直接抄。前提只有一个你得知道什么是typedef知道logic [7:0]是什么意思。其余的部分我都会从头拆。2. 枚举类型声明格式、基础类型与类型检查2.1 一行声明里藏着的六个组成部分最完整的一条枚举声明长这样typedef enum logic [2:0] { IDLE 3d0, RUN 3d1, DONE 3d2 } state_e;拆开看有六块typedef定义一个新类型名、enum关键字、基础类型logic [2:0]、成员列表、每个成员的显式值、最后的类型名state_e。这六块里最容易被省略的是基础类型省略之后默认是int也就是 32 位有符号整数。为什么我强烈建议把基础类型写死三个理由。第一是波形。枚举变量在波形工具里默认按基础类型宽度显示一个只有三个状态的状态机占 32 位波形里全是高位补零看得很累。第二是隐式转换。枚举参与算术运算时会先转成基础类型位宽越大越容易在不该有符号的地方冒出负数。第三是可综合性。综合工具对枚举的处理依赖基础类型写死了位宽综合出来的寄存器和你想的完全一致。基础类型只能选整型bit、logic、reg、int、byte、shortint、longint以及它们的带符号版本。real、string、time这些都不行编译直接报错。2.2 值分配与默认值0 号坑成员值不写的时候从 0 开始依次加一。{IDLE, RUN, DONE}等价于{IDLE0, RUN1, DONE2}。写了值也可以跳着写比如{A1, B5, C9}只要同一个枚举内部的值互不相同就行重复了编译期直接报enum value already exists。真正隐蔽的坑在默认值上。SV 里所有变量声明后未初始化时都会被赋成全 0枚举变量也一样。如果你的成员列表里没有值为 0 的成员那么这个变量在初始化前就是一个非法枚举值——它不报错编译能过仿真也能跑但拿它去case会走到default拿它去.name()会返回空字符串。typedef enum logic [1:0] {A 2d1, B 2d2} e_t; e_t v; // 此时 v 的值是 2d0不是任何合法成员 $display(v%0d name[%s], v, v.name()); // 输出 v0 name[]我的习惯是永远让第一个成员显式等于 0并且给它起一个语义明确的名字比如IDLE、INVALID、NONE。这样未初始化状态至少是个讲得通的值而不是一个谁也不认识的 0。2.3 整型与枚举之间的转换静态转换和 $cast 的区别枚举和整数之间的赋值不是随便来的。整型变量赋给枚举变量编译器会报错必须显式转换。这里有两种写法行为完全不同int i 5; state_e s1, s2; s1 state_e(i); // 静态转换不做任何检查5 直接塞进去 if (!$cast(s2, i)) // 动态转换检查 5 是不是合法成员 $error(i%0d 不是合法状态, i);静态转换state_e(i)其实就是截断不检查合法性。如果i的值不在成员列表里s1就变成了一个非法值后续所有判断都会出问题而且没有任何提示。$cast则会检查不合法时返回 0目标变量保持原值不变这是唯一安全的做法。枚举转到整型反而是宽松的直接赋值就行不用转换因为整型能装下所有合法的枚举值。我个人的规范是枚举到整型随便转整型到枚举一律用$cast静态转换只在明确知道值合法的场合比如复位时赋 0使用。2.4 随机约束下枚举怎么用枚举天生适合做随机变量因为它的取值空间已经被限定死了。在类里声明rand枚举成员randomize()只会随出合法成员不会随出非法值class op_cfg; rand state_e st; rand int delay; constraint c_st { st inside {IDLE, RUN}; st ! DONE - delay inside {[1:10]}; } endclassinside对枚举是直接支持的dist权重分布也能用比如st dist {IDLE : 3, RUN : 7}。有一个细节值得注意枚举变量在randomize()之前如果是非法值比如未初始化的 0randomize()会把它修成一个合法值这点比手写logic [2:0]加约束可靠得多。另外提一句如果你用一个logic [2:0]加constraint {x inside {[0:2]}}来模拟枚举覆盖率采样时会多出一堆非法 bin还得手动写illegal_bins用枚举就没这个问题。3. 枚举的内建方法first/next/num/name 的正确用法3.1 遍历枚举的四种写法与回绕陷阱枚举不能用整数下标索引state_e[i]这种写法编译不过。遍历靠first()、last()、next()、prev()四个方法它们都以ref方式传入一个枚举变量返回bit表示是否成功。最常用的是正序遍历state_e s; if (s.first()) begin do begin $display(member: %s %0d, s.name(), s); end while (s.next()); end这里有个必须知道的机制next()走到最后一个成员时会返回 0 并把变量回绕到第一个成员。所以while (s.next())循环在拿到 0 的时候退出此时s已经变成first了不要再拿它当当前最后一个用。如果写成while (1) { ... s.next(); }这种不判返回值的写法就是标准的死循环仿真直接卡住而且 CPU 只占一个核看起来像跑得很慢很难第一时间想到是这里。prev()同理走到第一个成员时返回 0 并回绕到最后一个。num()返回成员个数可以拿来做循环计数上限但它不能替代.next()因为枚举值不一定连续num()只能告诉你有几个不能告诉你下一个是谁。3.2 name() 的返回值和非法值处理name()返回当前值的字符串形式也就是声明时写的那个成员名。这是调试时最省事的一招$display(%s, st.name())出来的就是IDLE、RUN这种可读文本比%0d强太多。但它对非法值不友好如果变量的值不在成员列表里name()返回空字符串$display打出来就是一对方括号[]容易被当成格式串写错。我的做法是封一个打印函数function string st2str(state_e s); string n s.name(); return (n.len() 0) ? $sformatf(ILLEGAL(%0d), s) : n; endfunction这样非法值也能打出数值排查时直接定位到哪个环节塞了脏数据。要注意name()是仿真器特性绝大多数商用和开源仿真器都支持但如果你的代码要过某些老工具最好自己维护一份case映射兜底。3.3 覆盖率与波形里的枚举覆盖率这边枚举是最好用的类型直接coverpoint一个枚举变量工具会自动给每个成员生成一个 bin不用手写covergroup cg_state; cp_st: coverpoint st; endgroup如果成员数量少、你想合并 bin可以写bins low {IDLE, RUN};。想统计非法值的话加illegal_bins bad {[3:$]};需要小心因为枚举本身的取值空间就是基础类型的全空间这样写会把所有非成员值都纳入非法 bin仿真中一旦出现就会报错反而帮你抓到了 bug。波形这边枚举变量在主流波形工具里会直接显示成员名前提是编译时带了调试信息。如果你发现波形里还是一堆数字先确认基础类型是不是写得太宽再看编译选项有没有关掉 debug。4. 字符串类型它真不是字符数组4.1 声明、赋值语义与内存行为string是 SV 的内建类型不是byte数组的别名。它的三个关键特性和 C 里的char*完全不同第一值语义。string a abc; string b a;之后改b不会影响a赋值是深拷贝。这跟 C 的指针语义正好相反从 C 转过来的同学第一次遇到会很不习惯。第二动态长度。声明时不需要指定长度赋值多长就是多长a hello之后再a hi长度自动从 5 变成 2没有截断也没有残留。第三默认值是空串。string s;之后s.len()是 0s 为真。它不是 null不能拿s null去判断这个判断在 SV 里没有意义。单个字符的访问用下标s[i]返回一个byte8 位下标 0 对应最左边的字符越界返回 0。注意它是byte不是string所以s[0] a这种写法比较的是字节和字符串语义上不严谨建议用s.getc(0)或者s[0] 8h61。4.2 高频方法逐个过一遍字符串的方法数量不多但常用的就那些我整理成一张表写代码时对着查方法作用返回值备注len()字符个数int空串返回 0getc(i)取第 i 个字符byte越界返回 0putc(i, c)改写第 i 个字符void原地修改不返回新串substr(i, j)取子串string包含 i 和 j 两端toupper()转大写string返回新串原串不变tolower()转小写string同上compare(t)字典序比较int返回 -1 / 0 / 1区分大小写icompare(t)同上int忽略大小写atoi()转十进制整数int遇到非法字符停止atohex()转十六进制整数int同上atoreal()转实数real同上有几个点容易错。substr(i, j)的两个参数都是闭区间hello.substr(1, 3)得到ell不是el。toupper()和tolower()不修改原串必须接返回值写成s.toupper();然后继续用s是没效果的。compare()返回的是三态值不是布尔值写if (s.compare(t))会把相等判成假、把不等判成真逻辑正好反了应该写if (s.compare(t) 0)或者直接用。4.3 数值与字符串互转的细节atoi()这类方法的行为是从左往右扫遇到第一个不认识的字符就停。所以12abc.atoi()得到 12abc.atoi()得到 0-42.atoi()得到 -42。这个特性在做配置解析时很有用但也容易掩盖错误用户写了3.5atoi()只给你 3不报错。反方向整数转字符串用的是整型自己的方法写法是变量.itoa()int i 255; string s1 i.itoa(); // 255 string s2 i.hextoa(); // ff string s3 i.bintoa(); // 11111111 string s4 i.octtoa(); // 377注意itoa()、hextoa()这些是整型的方法不是字符串的方法所以不能写abc.itoa()。字面量要调用得先转一下int(42).itoa()虽然丑但能跑。atoreal()是唯一返回real的其余的ato*都返回整型。如果字符串带前缀比如0x1Fatohex()是能处理的但保险起见我一般先剥掉前缀再转避免不同仿真器行为差异。4.4 拼接、格式化与日志性能字符串拼接用花括号{}这个符号在 SV 里是位拼接和字符串拼接共用的靠操作数类型自动区分string msg {FAIL at addr , addr.hextoa(), expect , exp.hextoa()};语法上没问题但性能上要注意每次拼接都会创建新的字符串对象。在循环里拼 10 万次仿真时间能明显看出差异。我实测过一个 case把循环内的逐行拼接改成先用队列收集、最后一次性join用$sformatf循环生成后拼一次耗时从 4 秒多降到 0.8 秒左右差距相当明显。更好的做法是用$sformatf一次性格式化string msg $sformatf(FAIL at addr0x%0h expect0x%0h got0x%0h, addr, exp, act);$sformatf返回string可以直接赋给变量、塞进队列、写进文件比$display灵活。$display是直接输出到标准输出的没法二次处理。5. 枚举与字符串互转三种落地方案5.1 枚举转字符串能用 name() 就别自己维护表正向转换最简单.name()一行搞定。但有一种情况需要自己维护映射你想输出的不是成员名而是给人看的中文或更详细的描述。比如状态WAIT_ACK你想显示成等待应答这时候就得写一个查找函数function string st_desc(state_e s); case (s) IDLE: return 空闲; RUN: return 运行中; DONE: return 已完成; default: return $sformatf(未知状态(%0d), s); endcase endfunction这里必须写default因为枚举的基础类型宽于成员数量非法值是客观存在的。漏了default的case在仿真时会走空分支函数返回默认值字符串是空串日志里就是一片空白排查起来很烦。5.2 字符串转枚举三种方案的取舍反向转换 SV 没有内建支持得自己写。我试过三种方案各有适用场景方案实现方式优点缺点适用场景case 语句手写分支直观、速度快加成员要改代码成员少且稳定关联数组声明时建表加成员只改表占内存、要维护成员多、需要反查反射遍历循环first/nextname()加成员零改动每次调用 O(n)通用工具函数反射遍历这个方案我觉得最值得推荐因为它完全不需要维护映射表枚举加成员自动生效function bit str2st(string s, ref state_e out); state_e tmp; if (!tmp.first()) return 0; do begin if (tmp.name() s) begin out tmp; return 1; end end while (tmp.next()); return 0; endfunction用的时候if (!str2st(cfg_str, st)) $error(未知状态名 %s, cfg_str);失败有明确返回调用方不会拿到脏值。代价是每次调用要遍历一遍成员成员数在十几个以内完全没有性能问题超过五十个才需要考虑换关联数组。关联数组方案长这样适合需要频繁反查的场合state_e name2st[string]; initial begin name2st[IDLE] IDLE; name2st[RUN] RUN; name2st[DONE] DONE; end if (name2st.exists(cfg_str)) st name2st[cfg_str];exists()一定要判直接name2st[cfg_str]访问不存在的键会返回默认值 0正好又踩回 2.2 节那个0 号坑。5.3 一个配置驱动的用例骨架把上面这些拼起来就是一个典型的从文本配置驱动测试状态的骨架class test_cfg; state_e st; int delay; function bit load_from_file(string path); int fd $fopen(path, r); string line; if (fd 0) return 0; while ($fgets(line, fd)) begin string kv[$]; line line.tolower(); // 去掉行尾换行 if (line.len() 0 line[line.len()-1] \n) line line.substr(0, line.len()-2); // 空行和注释直接跳过 if (line.len() 0 || line[0] #) continue; // 按等号切 foreach (line[i]) begin if (line[i] ) begin string k line.substr(0, i-1); string v line.substr(i1, line.len()-1); if (k state) begin if (!str2st(v, st)) begin $error(配置里的 state%s 不认识, v); return 0; end end else if (k delay) delay v.atoi(); break; end end end $fclose(fd); return 1; endfunction endclass这段代码里用到了字符串的tolower、substr、下标访问、atoi以及str2st的反射转换。实际跑起来要注意几点$fgets返回的串会带换行符不去掉的话k state永远为假因为实际是state\ntolower()会返回新串必须重新赋值给line不能只调不接foreach (line[i])遍历的是字节break只跳出foreach不跳出while我上面用了一个标志位控制写成return更省事。6. 踩坑与排查速查表6.1 编译期报错对照表报错关键词典型原因处理方式enum value already exists成员显式值重复检查所有后面的数illegal assignment to enum整数直接赋给枚举改用$cast或静态转换type incompatible两个不同枚举类型互相赋值先转成整型再转回去string not allowed in enum基础类型写成了非整型改成int/logic等cannot index a non-array对枚举用[]用first/next遍历编译期错误基本都是一眼能看出来的改起来不费劲。真正花时间的是运行期。6.2 运行期诡异现象现象根因排查手段case走到default但状态看着正常变量是非法枚举值打印%0d看数值name()为空即可确认仿真卡住、CPU 占满单核next()死循环检查while条件是否用了返回值日志字符串末尾多出乱码拼了byte而不是string用getc()的结果转字符串再拼两个字符串看着一样但不相等大小写差异或末尾空白用compare()打印返回值或len()对比长度随机化后状态还是非法值用了logic而非枚举换成枚举类型仿真器会自动约束波形里枚举显示成数字编译没带 debug 信息检查编译选项或临时用name()打印后面这个表我建议直接贴到自己项目的doc目录里新同事进来排查问题能省不少时间。很多问题其实不是 SV 的坑是你以为它会这么做、它其实不这么做的认知偏差。6.3 几条我一直在用的规范第一条枚举第一个成员显式等于 0并且名字带语义比如IDLE、NONE、INVALID。这样未初始化状态下也不会出现谁也不认识的值。第二条枚举变量的声明处就初始化。state_e st IDLE;多打这几个字比后面查半天非法值划算得多。第三条日志里打印枚举一律用name()包一层封装的转换函数不要直接用%s或者%0d。前者对非法值输出空串后者输出没人看得懂的数字。第四条字符串转枚举的入口只留一个函数所有配置解析都走它。这样将来要加校验、加默认值、加日志只改一处。第五条循环里不要做字符串拼接。要么用$sformatf一次成型要么先攒进队列最后统一处理。仿真性能这个问题平时不觉得跑长回归的时候一次能省几十分钟。最后分享一个我踩过的坑有次写了个str2st的实现在do-while里用tmp.next()作为循环条件忘了当传进来的字符串恰好是最后一个成员时next()会返回 0 并且把tmp回绕到第一个导致匹配判断在回绕之后才执行永远匹配不上最后一个成员。这个问题在成员只有三四个的时候完全没暴露因为测试用例基本都在前两个状态上跑加到第七个成员之后才冒出来。后来我把匹配判断放在循环体开头把回绕当成正常的循环结束条件处理才彻底修掉。枚举的.next()语义就是这样它不是下一个数值而是列表里的下一个成员回绕是设计好的行为用的时候必须把这个行为考虑进去。
阅读完成 · 觉得有帮助?