ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SystemVerilog 实现 32 位最低位优先编码器:从接口约定到 XSim 自检

SystemVerilog 实现 32 位最低位优先编码器:从接口约定到 XSim 自检

SystemVerilog 实现 32 位最低位优先编码器:从接口约定到 XSim 自检

多个请求同时置位时,优先编码器把请求向量转换成一个索引。本文实现的是最低索引优先:request[0]优先级最高,request[31]最低。输入不是 one-hot 也可以工作;输入全零时输出valid=0、index=0。

本文代码和测试为本次独立编写。实际使用 Windows 上的 Vivado/XSim 2022.1 运行 RTL 仿真,累计完成 141,908 次输入/输出检查。验证范围是组合逻辑功能,不包含器件综合、布局布线、板级验证或时序收敛。

1. 先约定“最低位”究竟是什么

向量声明为[31:0],bit0 是最低有效位,也就是数值权重为 1 的那一位。这里的“优先”不表示数值较大的请求获胜,也不表示找到从左向右遇到的第一个 1。

32 位输入validindex原因
32'h0000000000没有请求,索引固定清零
32'h0000000110bit0 请求
32'h80000000131只有 bit31 请求
32'h8000000110bit0 的优先级高于 bit31
32'h80018000115bit15、bit16、bit31 同时请求

index=0有两种可能:没有请求,或者 bit0 获胜。因此,下游必须同时使用valid。索引不能独自承担“是否存在请求”的含义。

接口是纯组合接口,没有时钟、复位或握手。输入变化并经过组合逻辑传播后,输出对应当前输入。模块没有内部寄存器延迟;这也不意味着物理传播延迟为零。

2. 可参数化的完整实现

默认参数是 32 位。W要求为正整数;W=0和负值不属于接口支持范围。IW是不可覆盖的局部参数,从W自动计算,避免实例化者把输出索引宽度配错。

文件:priority_encoder.sv(priority_encoder.sv)。以下代码与本次通过仿真的核心源文件一致。

// Combinational lowest-set-bit encoder. Contract: W >= 1, binary inputs. module priority_encoder #( parameter integer W = 32, localparam integer IW = (W <= 1) ? 1 : $clog2(W) ) ( input logic [W-1:0] request, output logic valid, output logic [IW-1:0] index ); always_comb begin valid = 1'b0; index = '0; // Later blocking assignments win; visit bit0 last. for (integer bit_index = W-1; bit_index >= 0; bit_index = bit_index-1) begin if (request[bit_index]) begin valid = 1'b1; index = IW'(bit_index); end end end endmodule

这个实现从高位扫描到低位。组合过程使用阻塞赋值,后执行的赋值覆盖先执行的赋值。因此,对于32'h80018000,索引先写入 31,再写入 16,最后写入 15。最终保留下来的是最低置位位置。

这一点很容易写反:如果保持“命中就覆盖”的写法,却把循环改为从 0 扫描到 31,得到的就是最高位优先。循环方向和是否允许覆盖必须一起理解。

进入组合过程后先给两个输出赋默认值。这样输入全零时不会保留上次的索引,也不存在需要保持旧输出的逻辑分支。IW'(bit_index)显式转换成索引位宽。由于循环范围为0到W-1,有效索引始终可表示。

WIW有效 index 范围
110
530~4
830~7
3250~31

W=1需要单独考虑:$clog2(1)为 0,而接口仍然保留一个物理索引位。对于非二次幂宽度,例如W=5,输出虽然有 3 位,但有效请求不会产生 5、6、7 这些索引。

本文只约定由 0/1 构成的请求输入。不对包含X或Z的输入承诺结果,也不把仿真中未知值的处理方式当作硬件安全机制。

3. for 循环与组合路径

AMD 的 UG901 说明,Vivado 支持常量边界的for循环,以及常量步长的递增、递减形式。本例的W是展开时确定的参数,循环上下界和步长均可静态确定。参见官方 UG901:Using For Statements,2024.1 版;该链接用于说明语法支持,本文实际仿真工具版本为 2022.1。

这里没有时钟边沿或迭代状态,RTL 中的循环描述的是组合函数。不能把 32 次过程迭代理解为“硬件运行 32 个时钟周期”。同样,代码短也不代表逻辑级数少:优先选择关系可能形成较长的组合路径,具体结构取决于综合优化和目标器件。

工程接入时,把valid与index视为同一份组合结果。在时钟域中使用它们,应确保输入到接收寄存器的路径满足建立和保持时间,并一起采样这两个信号。异步请求还需要调用方安排相应的跨时钟域处理。本文没有提供寄存包装层,因此也没有额外的“第几拍输出”协议。

4. 测试不能照抄实现

如果测试也从高位向低位扫描并覆盖索引,设计和测试可能犯同一个方向错误。因此,自检测试采用另一种数学关系:对非零输入x,在固定 W 位宽内计算

lowest = x & (~x + 1) index = popcount(lowest - 1)

lowest只保留最低的一个 1。若最低位位于 k,那么lowest=2^k,lowest-1的低 k 位恰好全为 1,统计其中 1 的数量就得到 k。全零输入单独返回索引 0,避免把全零情况下的减法下溢当成有效索引。

测试任务中对应的核心代码如下,完整测试入口为tb_priority_encoder.sv(tb_priority_encoder.sv) 中的tb_priority_encoder:

lowest = value & (~value + 1'b1); expected = (value == '0) ? 0 : $countones(lowest - 1'b1); #1; checks = checks + 1; assert ((valid === (value != '0)) && (index === IW'(expected))) else $fatal(1, "output mismatch");

这里#1是测试台留给组合求值的仿真时间间隔,不是被测模块的时钟周期,也不是测得的硬件延迟。使用===比较,能让输出出现未知值时确定地判为不匹配。

测试先写好,再放入一个可编译、始终输出零的错误桩。实际 RED 运行在 2 ns 处触发断言:

Fatal: FAIL W=1 category=bit0 input=1 expected valid=1 index=0 got valid=0 index=0 check=2

这说明失败来自可观察行为,而不是语法、链接或工具安装错误。之后才替换为前面的完整 RTL,并运行同一套测试。原始错误桩保存在red_stub.sv,当时的源文件快照和日志保存在evidence/red/。

5. 覆盖范围与实测结果

四个参数实例并行运行,覆盖范围如下:

实例检查内容检查次数
W=1初始零、bit0、全部 2 个输入、结束零5
W=5初始零、bit0、全部 32 个输入、结束零35
W=8初始零、bit0、全部 256 个输入、结束零259
W=32下述定向、扫描与随机集合141,609
合计每次检查同时比较 valid 和 index141,908

计数口径:每个输入向量执行一次包含两个比较条件的即时assert,因此通过的输入向量数和功能断言执行次数均为 141,908;没有把两个输出字段分别计数。watchdog 是独立的超时失败保护,不计入这个通过总数。

32 位实例包含 32 个单比特位置、全部 496 种双比特组合、6 个多比特模式、低 16 位全部 65,536 个值、高 16 位全部 65,536 个值、10,000 个确定性伪随机值,以及初始零、bit0、结束零三个检查。多比特模式包括全 1、15/16/31 边界、16/31 边界、0/31 边界和两种交替位模式。

低半字扫描时高半字为零,高半字扫描时低半字为零;这些扫描不会覆盖所有 32 位组合。跨半字优先级由全部双比特组合、定向多比特模式和随机输入补充验证。32 位输入空间有 2^32 个值,本测试没有声称对其穷举。

随机序列使用固定初值32'h6d2b79f5 ^ W和显式 xorshift32 更新,因此不依赖仿真器默认随机种子。完成后再检查一次零输入,用来捕捉输出意外保留前次索引的问题。另设 200,000 ns 的仿真 watchdog;若测试没有按期完成,会触发$fatal。

2026-11-01 在 Windows 上实际运行得到:

Vivado Simulator v2022.1 xsim v2022.1 (64-bit) SW Build 3526262 PASS W=1 checks=5 PASS W=5 checks=35 PASS W=8 checks=259 PASS W=32 checks=141609 ALL PASS checks=141908 widths=1,5,8,32 $finish called at time : 141609 ns

141,609 ns 是测试台施加全部向量所需的仿真时间,不是编码器处理一次输入所需的时间,也不是仿真的墙钟耗时。

xvlog、xelab、xsim在 GREEN 运行中的退出码均为 0。需要注意,实测 RED 运行中 XSim 遇到$fatal后也返回了 0,所以仅检查进程退出码不够。作者内部复跑脚本会同时要求精确的总通过标记,并拒绝失败或 watchdog 文本。作者留存的完整标准输出、标准错误、命令参数、工具版本横幅和源文件 SHA256 保存在evidence/green/manifest.json(evidence/green/manifest.json) 及相邻日志中。

6. 在本机复现

需要已有 Vivado 2022.1 安装。在已配置 Vivado 命令环境的终端,将本文的priority_encoder.sv与第8节完整测试台tb_priority_encoder.sv放在同一临时目录,执行:

xvlog --sv priority_encoder.sv tb_priority_encoder.sv xelab tb_priority_encoder --timescale 1ns/1ps --snapshot priority_sim xsim priority_sim --runall

--sv是必要的,因为源码使用 SystemVerilog。不要把其他测试模块一起编入这个快照。成功时应看到ALL PASS checks=141908 widths=1,5,8,32,并且没有 Fatal 或 watchdog 失败;仅凭进程退出码0不够。每次重新验证请使用独立目录保留旧日志。

核心文件的 SHA256 为F0145822A2FF96E178FE234D5D739C36522E3655A4F16F956EA65903EBB5573C。

7. 独立复核与变异检查

除作者测试外,独立复核者重新编写了测试,参考模型采用移位直到遇到第一个 1 的方法。该测试穷举 W=1、3、5、8、9,并对 W=32 增加定向和混合输入;总共执行 3,752 次断言,全部通过,仿真结束时间为 1,066 ns。这里的计数遵循独立测试自身的断言口径,不与前文的输入向量计数混用。详见../review/priority/baseline-final/simulate.stdout.log(独立基线运行日志)。

复核者还在隔离副本中注入两种错误,并用真实 XSim 重新编译、展开和运行:

注入错误运行时捕获的反例结果
反转遍历方向,变成高位优先W=32,输入ffffffff,期望 0,实际 312 ns 触发断言失败
全零输入错误地输出 valid=1W=1,输入 0,实际 valid=11 ns 触发断言失败

这两种变异都成功编译和展开,失败来自功能断言。对应的../review/priority/reverse-final/simulate.stdout.log(反向优先级日志) 和../review/priority/zero-valid-final/simulate.stdout.log(全零有效位错误日志) 保留了反例;各目录的commands.json记录命令、标准输出与标准错误路径、进程退出码。变异副本未替换本文核心源文件。

实际工程若提高位宽或目标频率,应根据所选 FPGA、约束、综合与实现报告评估组合路径,再决定是否采用分层编码或增加流水级。本次结果能够支持上述接口在已测输入下的 RTL 行为正确性,不能替代具体器件上的时序分析。

8. 可直接保存的完整测试台

下面是本次通过原生仿真的完整测试台。源码头部的 “Mutations caught” 列举测试拟捕捉的错误类别;本次真正执行的行为变异只有前文明确列出的两种,不把注释清单当成额外实测结果。将核心 RTL 和测试台分别保存为对应文件,放到同一临时目录,在已配置 Vivado 命令环境的终端执行前文的 xvlog、xelab、xsim 命令即可复现。本文提到的 evidence/review 路径是作者留存证据的位置,不是公开下载链接。

`timescale 1ns/1ps // Mutations caught: reversed priority, wrong valid, stale index on zero, // missing high bits, width truncation, and W=1 zero-width port mistakes. module priority_case #(parameter integer W=32, parameter integer IW=(W<=1)?1:$clog2(W)) (output logic done, output integer checks); logic [W-1:0] request; wire valid; wire [IW-1:0] index; logic [31:0] rng; integer a, b, n; logic [W-1:0] sample; priority_encoder #(.W(W)) dut (.request(request), .valid(valid), .index(index)); // Independent oracle: isolate the lowest bit with two's complement, // then count the ones below that isolated bit. No priority scan. task automatic check(input logic [W-1:0] value, input string category); logic [W-1:0] lowest; integer expected; begin request = value; lowest = value & (~value + 1'b1); expected = (value == '0) ? 0 : $countones(lowest - 1'b1); #1; checks = checks + 1; assert ((valid === (value != '0)) && (index === IW'(expected))) else $fatal(1, "FAIL W=%0d category=%s input=%h expected valid=%0d index=%0d got valid=%b index=%0d check=%0d", W, category, value, (value != '0), expected, valid, index, checks); end endtask initial begin done = 0; checks = 0; request = '0; rng = 32'h6d2b79f5 ^ W; check('0, "zero"); // Deliberately first nonzero check: a constant-zero stub MUST fail here. check({{(W-1){1'b0}}, 1'b1}, "bit0"); if (W <= 8) begin for (n=0; n<(1<<W); n=n+1) check(W'(n), "exhaustive"); end else begin for (a=0; a<W; a=a+1) check(W'(1) << a, "all-single-bits"); for (a=0; a<W; a=a+1) for (b=a+1; b<W; b=b+1) check((W'(1)<<a)|(W'(1)<<b), "all-bit-pairs"); check('1, "all-ones"); check(32'h80018000, "boundary-15-16-31"); check(32'h80010000, "boundary-16-31"); check(32'h80000001, "boundary-0-31"); check(32'haaaaaaaa, "alternating-odd"); check(32'h55555555, "alternating-even"); // Exhaust each half independently, including the bit15/16 boundary. for (n=0; n<65536; n=n+1) begin check(W'(n), "low-half-sweep"); check(W'(n)<<16, "high-half-sweep"); end for (n=0; n<10000; n=n+1) begin rng = rng ^ (rng << 13); rng = rng ^ (rng >> 17); rng = rng ^ (rng << 5); sample = W'(rng); check(sample, "deterministic-random"); end end check('0, "zero-after-active"); $display("PASS W=%0d checks=%0d", W, checks); done = 1; end endmodule module tb_priority_encoder; wire d1, d5, d8, d32; wire [31:0] c1, c5, c8, c32; priority_case #(.W(1)) t1(d1,c1); priority_case #(.W(5)) t5(d5,c5); priority_case #(.W(8)) t8(d8,c8); priority_case #(.W(32)) t32(d32,c32); initial begin wait(d1 && d5 && d8 && d32); $display("ALL PASS checks=%0d widths=1,5,8,32", c1+c5+c8+c32); $finish; end initial begin #200000; $fatal(1, "WATCHDOG: test did not finish within 200000 ns"); end endmodule

9. 继续阅读与接入检查

32位 Popcount 三级流水讨论“有多少个1”,本文讨论“最低的1在哪里”,两者不能混用。若用于请求分配,还可对照四路轮询仲裁区分固定优先级与公平性。

接入前可以检查一个具体问题:如果 bit0 长期为1,bit31 的请求是否允许一直得不到服务?若不允许,除了编码器之外还需要怎样的仲裁状态?

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进