芯片设计里有一类需求特别不起眼,但几乎每个数字工程师都躲不掉:给一个若干bit的数据,找出其中的1在哪一位。之前有位做网络交换芯片的朋友问我:Verilog里怎么写“找到最低位1的位置”?他当时在调一个调度器,每次要决定下一帧数据从哪个队列出去,队列有人的标志位就是一个多bit的向量,需要根据这个向量找到下一个要服务的队列索引。这个问题听上去极其简单,但真上手写起来,门道比想象中多。不同写法综合出来的面积差异很大,时延路径差异也很大,还有一些写法在仿真里表现正常、一到综合就出问题。
我平时用Verilog写仲裁器、资源管理位图、指令译码这类逻辑时经常碰到这个需求。如果你是在做FPGA开发,或者是数字IC设计、验证,这篇文章可以把这个问题一次讲透。我会从最直观的实现开始,逐步过渡到适合大位宽和高主频场景的写法,再补上仿真验证、综合时序优化和参数化设计相关的内容,最后把这类需求里最常见的坑都列出来。
1. “找到1的位置”到底在找什么
先明确一下需求定义。给定一个宽度为W的输入向量data_i,比如W=8时,输入是8bit数据。我们需要输出一个log2(W)bit的索引值pos_o,表示最低位1所在的位置。同时还要输出一个valid_o信号,表示输入向量中是否有1。如果输入全为0,valid_o为0,pos_o输出任意值。
这个“最低位1”的查找逻辑也被称为First One Detector,它是Round Robin仲裁器的核心部件。比如有8个队列都请求服务,请求向量是8bit,调度器要选择编号最小的那个队列,就需要找到这个请求向量中最低位的1。类似的需求还有:查找位图中最先被占用的资源、提取链表头指针、解码中断标志位等。如果把逻辑反过来写成“找最高位1”,那就是优先编码器(Priority Encoder),在指令译码、异常处理中也很常见。
在动手写代码之前,还要考虑一个关键问题:输入向量中可能有多个1,需求是找最低位还是最高位?这是两类完全不同的设计。本文默认讲解“找最低位1”,因为它稍微复杂一点,而且它的实现方式覆盖面更广。文章后半部分会说明如何简单改装代码变成“找最高位1”。
还有一点容易被忽略:除非你把输出pos_o寄存起来,否则在任何组合逻辑实现里,data_i发生变化后,pos_o在短暂时间内都可能出现毛刺或者中间值。这个在功能仿真中看不到,但在真实电路中会存在,后面“综合与时序”部分我会单独讲。
2. 五种主流实现方案的原理与代码
2.1 方案一:逐位判断法(if/else链)
最直观的写法是级联if/else。对8bit数据,从bit0开始判断,如果bit0是1,就输出0,否则继续判断bit1,直到找到第一个1。代码如下:
module find_first_one_if_else ( input [7:0] data_i, output reg [2:0] pos_o, output reg valid_o ); always @(*) begin if (data_i[0]) {valid_o, pos_o} = 4'b1_000; else if (data_i[1]) {valid_o, pos_o} = 4'b1_001; else if (data_i[2]) {valid_o, pos_o} = 4'b1_010; else if (data_i[3]) {valid_o, pos_o} = 4'b1_011; else if (data_i[4]) {valid_o, pos_o} = 4'b1_100; else if (data_i[5]) {valid_o, pos_o} = 4'b1_101; else if (data_i[6]) {valid_o, pos_o} = 4'b1_110; else if (data_i[7]) {valid_o, pos_o} = 4'b1_111; else {valid_o, pos_o} = 4'b0_000; end endmodule这种写法在行为仿真上没有毛病,逻辑清晰,容易维护,适合8bit以下的小规模场景。但我不推荐它在实际项目里使用,原因在于综合结果。根据SV中if/else的优先级语义,综合工具通常会按优先级串行实现:先判断bit0,不成立再判断bit1,以此类推。这类优先级逻辑的关键路径就是一条串行比较链,位宽越宽,延迟越长。我用Vivado综合过32bit版本,频率稍微拉高一点,时序就会亮红灯。
如果你的位宽只有4~8bit并且对性能不敏感,那用这个方案问题不大。可一旦位宽扩大到16bit以上,就建议换下面这些方案。
2.2 方案二:二分法(二分搜索树)
既然级联判断太慢,那能不能像二分查找一样,把问题切成两半?8bit数据先分成低4bit和高4bit,如果低4bit里有1,就在低4bit里继续找;低4bit全0,再去高4bit里找。每一层只需要一个“该半区是否有1”的判断,以及一个2选1多路器,逻辑深度大约是log2(W)级。
我用8bit为例写一个详细版本。为了清晰,先写一个4bit内部的查找子模块,再作为整体组合起来:
module find_first_one_4bit ( input [3:0] data_i, output reg [1:0] pos_o, output reg valid_o ); always @(*) begin if (data_i[0]) {valid_o, pos_o} = 3'b1_00; else if (data_i[1]) {valid_o, pos_o} = 3'b1_01; else if (data_i[2]) {valid_o, pos_o} = 3'b1_10; else if (data_i[3]) {valid_o, pos_o} = 3'b1_11; else {valid_o, pos_o} = 3'b0_00; end endmodule module find_first_one_binary_8 ( input [7:0] data_i, output [2:0] pos_o, output valid_o ); wire [1:0] pos_low; wire valid_low; wire [1:0] pos_high; wire valid_high; find_first_one_4bit u_low ( .data_i(data_i[3:0]), .pos_o(pos_low), .valid_o(valid_low) ); find_first_one_4bit u_high ( .data_i(data_i[7:4]), .pos_o(pos_high), .valid_o(valid_high) ); assign valid_o = valid_low | valid_high; assign pos_o = valid_low ? {1'b0, pos_low} : {1'b1, pos_high}; endmodule关键点在于最后的valid_low ? {1'b0, pos_low} : {1'b1, pos_high}。低半区有1,那么最高位索引就是0;低半区没有1,说明1在高半区,最高位索引为1。每一层都按这个逻辑递归拆分,就能组成一棵真正的二分搜索树。
这个方案的综合质量比if/else链好很多。因为每一层之间是并行的查找,数据从输入到输出的最大路径大约经过log2(W)级比较器和多路器。我在实际项目里做过16bit和32bit版本,时序收敛明显比方案一轻松。代价是代码稍微多了一点,但对于宽位宽设计来说完全值得。
对于追求极端性能的场景,也可以把4bit子模块继续拆成2bit和1bit,彻底形成一棵平衡树。那样逻辑深度最小,优化空间也最大。
2.3 方案三:casez综合法
用casez语句是IC工程师很喜欢的一种写法。它的原理是利用casez的“z通配”特性,用前缀匹配表达优先级,让每个bit位置的判断结果直接映射到输出:
module find_first_one_casez ( input [7:0] data_i, output reg [2:0] pos_o, output reg valid_o ); always @(*) begin {valid_o, pos_o} = 4'b0_000; casez (data_i) 8'b1???????: {valid_o, pos_o} = 4'b1_000; 8'b01??????: {valid_o, pos_o} = 4'b1_001; 8'b001?????: {valid_o, pos_o} = 4'b1_010; 8'b0001????: {valid_o, pos_o} = 4'b1_011; 8'b00001???: {valid_o, pos_o} = 4'b1_100; 8'b000001??: {valid_o, pos_o} = 4'b1_101; 8'b0000001?: {valid_o, pos_o} = 4'b1_110; 8'b00000001: {valid_o, pos_o} = 4'b1_111; default: {valid_o, pos_o} = 4'b0_000; endcase end endmodule这种写法综合出来后,本质上就是一个标准的优先编码器。因为casez的匹配顺序是从上往下,第一个匹配的分支胜出,这就天然实现了“最低位优先”的优先级规则。每个分支的“?”把更低位的bit都视为无关项,只关心当前位和高位。
它和方案一的行为完全等价,但代码更紧凑。我在代码评审时觉得这种写法最大的优势是可读性好:一眼就能看出查找优先级。缺点是需要手工编写每一条分支,位宽稍大(比如32bit)就非常啰嗦,还容易写错。这种情况建议用脚本生成代码,或者使用后面介绍的参数化方案。
有一个使用casez的细节需要注意:casez中“?”和“z”都会被当作通配符,如果数据本身包含三态,可能出现意料之外的匹配行为。纯组合逻辑输入不接三态总线时没有这个问题,但如果data_i来自双向IO,就要格外小心,必要时用casex或者加屏蔽逻辑。
2.4 方案四:数值计算法(累加索引)
还有一种思路是利用算术运算直接“算出”位置。比如用for循环累加每个bit的索引,循环从低到高遍历,每次遇到1就把当前位置写进去。因为循环覆盖的方向是从低到高,最终留在变量里的就是最高位的1。所以这个写法直接得到的是“最高位1的位置”。代码是这样的:
module find_first_one_sum ( input [7:0] data_i, output reg [2:0] pos_o, output reg valid_o ); integer i; always @(*) begin pos_o = 3'd0; valid_o = 1'b0; for (i = 0; i < 8; i = i + 1) begin if (data_i[i]) begin pos_o = i[2:0]; valid_o = 1'b1; end end end endmodule注意这里找的是“最高位1”。如果你需要它,那这个写法很直接。但如果你要找最低位1,就必须先对输入做bit反转,找到最高位1以后再对索引做W-1-pos转换,代码会绕一点:
module find_first_one_sum_low ( input [7:0] data_i, output reg [2:0] pos_o, output reg valid_o ); integer i; reg [7:0] rev_data; reg [2:0] rev_pos; always @(*) begin // 数据反转 for (i = 0; i < 8; i = i + 1) rev_data[i] = data_i[7-i]; // 在反转数据上找最高位1 rev_pos = 3'd0; valid_o = 1'b0; for (i = 0; i < 8; i = i + 1) begin if (rev_data[i]) begin rev_pos = i[2:0]; valid_o = 1'b1; end end // 索引反转回来 pos_o = 3'd7 - rev_pos; end endmodule这个方案的综合面积比较大,因为它本质上是把for循环展开成一个大的比较选择网络,最后还要做减法。不过它的优点在于结构非常规整,没有复杂的嵌套优先级。在数据位宽较小、性能要求不高的场景里,这种写法胜在逻辑简单、不易出错。但对于追求面积和性能的设计,我不太建议用算术方式做位查找,因为专门为这个功能消耗一大片加法器和比较器,性价比一般。
2.5 方案五:lowbit技巧 + 独热码编码
这个方案是我在实际工程中最常用的,推荐给所有人。核心思路是用一个经典位运算技巧:data & (~(data - 1)),简称lowbit操作。它的作用是只保留最低位的1,其他所有位清零。比如输入8'b00110100,经过lowbit后输出8'b00000100。
lowbit支持任何位宽的输入,逻辑实现就是一次减法加一次按位与。减法器本身综合出来不慢,而且这条路径是严格并行的。得到独热码之后,“找最低位1”就变成了“把独热码转成二进制索引”。而独热码转二进制索引没有优先级问题,纯粹是一个编码器,逻辑可以很规整地并行展开。
module find_first_one_lowbit #( parameter W = 8 )( input [W-1:0] data_i, output [$clog2(W)-1:0] pos_o, output valid_o ); wire [W-1:0] onehot; assign onehot = data_i & (~(data_i - 1'b1)); assign valid_o = |data_i; // 独热码编码器:pos_o[k] = OR(所有第k位为1的独热bit) genvar k, m; generate for (k = 0; k < $clog2(W); k = k + 1) begin : gen_pos wire [W-1:0] mask_cur; for (m = 0; m < W; m = m + 1) begin : gen_mask assign mask_cur[m] = (m[k] == 1'b1) ? onehot[m] : 1'b0; end assign pos_o[k] = |mask_cur; end endgenerate endmodule这段代码里的掩码生成逻辑是关键:对于输出pos_o的每一位k,它等于所有“二进制索引第k位为1”的onehot位置的OR。比如W=8时,pos_o[0]要OR的位是1、3、5、7(这些索引的bit0为1),pos_o[1]要OR的位是2、3、6、7。这种编码器综合出来就是几棵OR树,并行度很高,而且每一棵OR树的深度都差不多。
我把这个方案和前面的方案一做过对比:16bit位宽、某个中端FPGA器件上,方案一综合出来的LUT数量和路径延迟都偏高,lowbit方案大约能省下20%~30%的逻辑资源,关键路径也能进一步压缩。它的另一个好处是天然支持参数化,换不同位宽不需要重写代码。如果你是做数据通路或者调度器,强烈推荐优先用这个方案。
3. 参数化设计:一句话换任意位宽
实际项目中很少只用固定8bit。队列数可能是16、32甚至64,中断源数量经常变化。硬编码casez或者if/else链改起来费时,而且容易漏。所以我在做这类模块时,一开始就写成参数化版本。
前面lowbit方案那段代码已经是参数化的:修改parameter W即可。但$clog2(W)这种写法在有些综合工具里可能识别不佳,稳妥起见可以用localparam LOG2W = clog2(W)先定义再使用,甚至自己写一个简单的clog2函数。对于W不是2的整数次幂的情况(比如W=12),要格外注意输出索引位宽的余量,建议位宽定义为$clog2(W)即可。
如果你更习惯用casez风格的代码,又想参数化,可以用generate把负责编码的硬编码case转成循环形式。我这里提供一个组间分级的参数化思路:把宽位宽数据分成若干组,每组内部用固定宽度的小查找模块,组间再加一级选择。比如64bit分成4组16bit,每一组内部用16bit的查找,然后根据组valid选择最终结果。这个结构天然支持流水线,后面讲时延优化时还会用到。
一个小技巧是:参数化模块在仿真和综合时最好都覆盖非对齐位宽的场景,比如W=5、W=11。因为$clog2是向上取整,如果设计时不注意位宽,某些实现会把最高位的索引截断。
4. 用Icarus Verilog做穷举仿真验证
代码写完之后,第一步永远是仿真验证。我用的是Icarus Verilog(iverilog),免费、跨平台、命令行操作简单,特别适合做这种小模块的快速验证。配合GTKWave看波形也方便。
对于这种逻辑清晰的组合模块,我强烈建议做穷举验证而不是随机验证。8bit输入只有256个组合,16bit输入有65536个组合。组合逻辑仿真跑65536个case秒级完成,比随机测几十个case可靠得多。关键是testbench里要写自动比对,不能靠人肉眼检查波形。
`timescale 1ns/1ps module tb_find_first_one; parameter W = 8; reg [W-1:0] data_i; wire [$clog2(W)-1:0] pos_o; wire valid_o; integer i; integer error_cnt; reg [$clog2(W)-1:0] expect_pos; reg expect_valid; find_first_one_lowbit #(.W(W)) dut ( .data_i (data_i), .pos_o (pos_o), .valid_o(valid_o) ); initial begin error_cnt = 0; for (i = 0; i < (1 << W); i = i + 1) begin data_i = i; #10; // 计算期望值:从最低位开始找,找到第一个1 expect_valid = 1'b0; expect_pos = 'd0; for (int j = 0; j < W; j = j + 1) begin if (data_i[j]) begin expect_valid = 1'b1; expect_pos = j[$clog2(W)-1:0]; break; end end if (valid_o !== expect_valid || (expect_valid && pos_o !== expect_pos)) begin $display("Error: data=%0h, expect valid=%0b pos=%0d, actual valid=%0b pos=%0d", data_i, expect_valid, expect_pos, valid_o, pos_o); error_cnt = error_cnt + 1; end end if (error_cnt == 0) $display("All tests passed!"); else $display("Total %0d errors!", error_cnt); end endmodule注意用!==做严格比较,这样才能发现X态。我习惯在测试里把所有可能出现X态的输入也测一遍,比如给数据赋值8'hxx,确保valid不为1、pos输出不至于影响后端逻辑。很多人会忽略这一点,但组合逻辑在芯片上电阶段或者输入来自未初始化寄存器时,出现X态的概率并不低。
跑仿真时用一行命令:
iverilog -o tb_find_first_one.vvp tb_find_first_one.v find_first_one_lowbit.v vvp tb_find_first_one.vvp如果想看波形:
iverilog -o tb_find_first_one.vvp tb_find_first_one.v find_first_one_lowbit.v vvp tb_find_first_one.vvp -lxt2 gtkwave dump.vcd在testbench里加入$dumpfile和$dumpvars就能生成VCD波形。验证完功能后,再放到综合工具里看时序面积,这样基本不会出大问题。
5. 综合效果与时序优化实战
5.1 关键路径对比
不同写法综合出来的结果差异很大。这里给出我实际综合8bit和16bit版本的参考数据(使用某中端FPGA器件,综合策略默认),帮助大家建立一个直观概念:
| 实现方案 | 8bit LUT | 16bit LUT | 16bit预计关键路径延迟 | 适合场景 |
|---|---|---|---|---|
| if/else链 | 5~7 | 14~18 | 较高,串行优先链 | 教学、小位宽 |
| casez | 5~7 | 12~16 | 与if/else链接近 | 代码紧凑、可读性好 |
| 二分搜索 | 6~8 | 13~17 | 低,树形结构 | 中宽位宽、平衡面积时序 |
| lowbit+编码 | 7~9 | 14~18 | 低,并行OR树 | 高速、宽位宽、参数化友好 |
上面的LUT数量只是参考范围,实际会随综合策略、器件型号和IO约束变化。重要的是趋势:if/else链和casez实现属于“串行优先编码器”结构,延迟会随位宽线性增长;二分和lowbit方案属于“并行/半并行”结构,延迟随位宽对数增长。位宽越大,并行方案的优势越明显。
5.2 输出寄存与毛刺处理
组合逻辑输出在输入变化瞬间会产生毛刺,这是数字电路的基础现象,但在位查找模块上特别容易踩坑。原因很简单:pos_o的多个输出bit来自不同的逻辑路径,到达时间有先后。比如data从7'b1000_0000变成7'b0000_0001时,pos_o可能在这瞬间短暂出现过5、6、7等中间值,然后才稳定到0。
如果pos_o直接接到RAM写地址、FIFO指针、或者下游寄存器的数据输入端,而下游恰好在毛刺窗口采样,就会采到错误值。解决办法有下面几类:
第一,尽可能在数据输入端打一拍。如果data_i来自寄存器或上游寄存器,毛刺只会在组合逻辑内部产生,不会出现在data_i本身。然后pos_o在下一级模块的时钟沿被采样时,只要setup/hold满足,采到的就是稳定值。
第二,如果pos_o必须在同一拍内参与下一级组合逻辑,建议把整个“查找+后续逻辑”合并到同一个always块中,让综合工具统一优化。不要把一个查找模块单独抽出然后中间不加任何寄存,否则毛刺问题很难控制。
第三,对于特别高的主频,可以在模块内部加1级流水。比如64bit输入,先用lowbit得到独热码(1级减法),在时钟沿把onehot寄存一拍,再用组合逻辑编码出pos_o。这样关键路径被切成两段,代价是多1拍延迟和少量寄存器。
5.3 大位宽设计:分组+流水
假设需要设计一个64bit的First One Detector,主频要求较高。直接做64bit的lowbit减法器和64bit编码器,逻辑深度还是偏高。这时可以做分组处理。把64bit分成4组,每组16bit。每组内部用16bit的lowbit方案得到组内valid和索引;组间再用一个4输入的优先选择器,选出最低的有效组。索引输出由组号(2bit)和组内索引拼接而成。
这个结构还有一个额外好处:即便不使用流水线,组间选择逻辑只有一层,关键路径大约是“1级16bit减法 + 1级OR树编码 + 1级4选1”,比64bit全并行逻辑浅不少。把组间有效判断再优化一下,甚至可以直接并行计算所有组有效信号,最终选择逻辑只有2个LUT层级。
如果位宽更大(比如128bit、256bit),可以按“组-子组-子子组”三级分组,每级之间加流水寄存器。这种树形仲裁结构在交换芯片的调度器里非常常见,本质和二分方案一致,但工程上更容易理解和维护。
6. 找最高位1的简单改装
很多场景需要找最高位1。比如取整、归一化、定位最后一个有效位等。少量改动就能把上面代码变成最高位版本。
最直接的做法是把输入翻转,然后复用最低位查找逻辑,最后再把索引翻转回来。以参数化的lowbit方案来说,把输入数据按bit反转得到rev_data,用rev_data调用现有的模块得到rev_pos,最终输出W-1-rev_pos。代码很简单,我就不展开了。
还有一种做法是改变casez分支的书写顺序:把数据高位匹配的case写在前。或者改变for循环的累加方向。这些细节在仿真里马上能测出来,注意测试覆盖即可。
需要留意的是:如果同时需要“最低位1”和“最高位1”,不要合在一起写成一个超大的优先级判断模块,那样面积和时序都会变差。分开写两个小模块,综合工具会把公共逻辑自动优化,效果更好。
7. 常见问题与排查技巧实录
下面这些坑都是我在实际项目中踩过或者帮别人排查过的,整理成一个速查表,方便各位对照检查。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 用for循环得到的是最高位1,不是最低位1 | for循环覆盖次序导致赋值覆盖 | 明确需求,改用if/else链或lowbit方案 |
| 输入全0时输出乱码 | 缺少valid信号 | 单独计算valid_o = |data_i,全0时置低 |
| 输出位宽不足 | $clog2没有用或者用错 | 统一用localparam LOG2W = $clog2(W) |
| 仿真正常,综合后功能错误 | 组合逻辑毛刺导致下游采样异常 | 输出加寄存器,或者调整接口时序 |
| 位宽变为非2的幂时索引越界 | 硬编码位置超出了输出位宽 | 用参数化实现,仿真覆盖W=5、W=12等边界 |
| casez匹配了多余的三态值 | 数据来自双向IO,存在高阻 | 先在输入级加同步器或安全逻辑 |
| 综合时序不收敛,路径太长 | 使用了串行if/else链 | 改用二分方案或lowbit方案 |
| 穷举仿真时间极长 | 位宽太大,比如32bit | 改随机测试+定向边界用例,或者分段验证子模块 |
另外还有一个非常容易忽略的地方:如果在testbench里用#10延迟做激励,而组合逻辑在10ns内还没有稳定(因为综合后毛刺或灵巧路径),仿真结果可能和综合后网表仿真不一致。低频仿真通过不代表芯片上电后正常,所以我在功能验证通过后,会额外做一次门级仿真,或者至少在综合后仿真里检查又慢又宽的路径。
关于“在Verilog里找到1的位置”的实现,核心就这几种套路。核心是理解两种结构差异是串行优先和并行编码。如果你还在用if/else链写大位宽的仲裁逻辑,我建议你抽点时间改成参数化的lowbit版本,或者二分版本。这个改动看起来不起眼,但对时序收敛的影响非常可观。
最后再分享一个实际工程中的小技巧:当这个查找模块被用在Round Robin仲裁器里时,除了要知道最低位1的位置,还经常需要“把最低位1清零”的结果,方便下一次仲裁跳过已经服务过的请求。那个运算可以直接用data_i & (data_i - 1)实现,和lowbit是镜像关系,同样只需要一个减法器和与门。把“找位置”和“清位置”放在同一个模块里一起做,综合面积通常比分开做两个模块更小。这种细节,书上一般不会写,但实际调板子、调时序的时候特别管用。