ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

独热码与二进制互转:原理、Verilog实现与避坑

独热码与二进制互转:原理、Verilog实现与避坑 一个 16 路轮询仲裁器grant 出来是 16 位独热码后级模块要的是 4 位索引中间那几行转换代码我来回改了三遍才稳下来。第一次是移位写法在 bin 等于 15 的时候把一位挤没了第二次是变量索引忘了给寄存器赋初值综合工具直接给我推断出一个锁存器第三次是独热码出现了两位同时为 1 的异常优先级编码和或树编码给出了两个完全不同的答案波形上看起来都对。独热码与二进制转换这件事单看逻辑门数少得可怜但真正落到工程里位宽边界、非法输入、综合结果、时序深度每一个都能让你在深夜对着波形发呆。这篇内容面向的是做数字逻辑、FPGA、嵌入式固件以及底层软件的同学只要你写过状态机、仲裁器、中断控制器、位图索引都会碰到这两个编码之间的来回转换。我会把两个方向的转换拆到数学层面讲清楚二进制转独热本质就是译码、独热转二进制本质就是索引提取这两句话到底意味着什么然后分别给出 Verilog 侧和软件侧的完整实现最后把我在实际项目里踩过的坑和排查路径原样摆出来。看完你应该能自己判断某个场景下该用哪种写法以及为什么。1. 独热码和二进制码各自在解决什么问题1.1 独热码用位的位置表示状态独热码的规则很简单N 位宽里任意时刻只有一位是 1其余全是 0。16 位独热码正好能表示 16 个状态第 0 个状态是16h0001第 15 个状态是16h8000。它的信息密度极低16 位只装了 16 个状态而同样 16 位用二进制能装 65536 个状态。但它的价值不在信息密度而在于状态判断退化成了位判断想知道当前是不是第 7 个状态直接看state[7]就行一个与门都不用。这个特性在状态机里非常关键下一状态的每个触发器输入只依赖少数几个当前状态位综合出来的组合逻辑浅、扇出小时序容易收敛。代价是触发器用量按状态数线性增长。1.2 二进制码把状态压进 log2(N) 位二进制码的思路正好反过来用最少的位表示最多的状态。16 个状态只需要 4 位触发器省了四分之三。但每次要判断当前是不是第 7 个状态都得做一次完整的比较4 位全部对上才算这 4 位分散在多个触发器上比较逻辑要跨越多个 LUT 层级。状态数一多译码逻辑的深度就成了关键路径上的大头。所以二进制码在 ASIC 里更常见因为面积是真金白银在 FPGA 里触发器资源相对充裕独热码反而更受欢迎。两种编码的取舍本质上就是面积换速度还是速度换面积没有绝对优劣。1.3 转换为什么躲不开现实工程里你很难只用一种编码走到底。仲裁器用独热码做 grant因为每个请求者只关心自己那一位硬件上就是一个优先级的位向量但日志、计数、DMA 描述符、寄存器索引这些地方需要的是一段紧凑的二进制编号这时候必须转。反过来从软件传来一个通道号要打开对应的那一路使能就得把二进制转成独热。转换逻辑本身不大但它是典型的高频公共路径一旦有 bug 会污染整条链路而且它的错误往往不是崩溃式的而是大多数时候对、边界时候错这种 bug 最难查。1.4 顺手澄清一个同名不同物搜独热码的时候会看到大量机器学习的内容那边说的独热编码是把类别标签展开成一个长长的稀疏向量比如三分类的第三个类别写成[0,0,1]。它和数字电路里的独热码在形式上完全一样都是只有一位有效但目的不同ML 里的独热是为了消除类别之间的序关系硬件里的独热是为了让状态判断退化到位运算。两者之间确实可以做转换但工程语境下说到独热码与二进制转换绝大多数情况指的是硬件状态编码和索引之间的互转后面所有内容都按这个前提展开。2. 转换的数学骨架就是译码和索引提取2.1 二进制转独热本质是一个 N 选 1 译码器把一个 k 位二进制数 b 转成 N 位独热码N 等于 2 的 k 次方本质就是让第 b 位输出 1、其余输出 0这就是教科书上的译码器。每个输出位 onehot[i] 都等于输入是否等于 i这个比较结果展开成布尔表达式就是 k 个输入项按位取反或原样后的与。以 4 位输入为例onehot[13]对应4b1101表达式是bin[3] bin[2] ~bin[1] bin[0]。整个译码树的深度大概是 log4(N) 级 LUT因为一个四输入 LUT 能吃掉 4 个输入信号16 个输出的译码器在 FPGA 里通常两三级就能搭完。这也是为什么二进制转独热在硬件里从来不是性能瓶颈。2.2 独热转二进制有两条完全不同的路第一条路是优先级编码器。从低位扫到高位遇到第一个 1 就把它的下标输出。写法上就是一个 for 循环循环体里if (onehot[i]) bin i。它的硬件形态是一条级联链每一位都要等前一位的判断结果深度随位宽线性增长16 位大概 16 级32 位就 32 级这是它在宽位宽下的致命伤。第二条路是按位或树。这个写法不容易想到但非常漂亮二进制结果的第 k 位等于所有下标第 k 位为 1的那些独热位的或。比如 16 位独热码里bin[0]就是onehot[1] | onehot[3] | onehot[5] | ... | onehot[15]所有奇数下标的或bin[1]是onehot[2] | onehot[3] | onehot[6] | onehot[7] | ...bin[3]最简单就是onehot[8] | onehot[9] | ... | onehot[15]。这个结构天然是二分递归的深度只有 log2(N)16 位四层、32 位五层而且不依赖优先级任意多位为 1 时它的输出是各下标按位或的结果。这个不依赖优先级既是优点也是陷阱后面第 5 章会专门讲。2.3 位宽和边界值必须先约定清楚转换逻辑的第一行代码应该是位宽定义而不是循环体。N 位独热码对应的二进制位宽是 ceil(log2(N))Verilog 里写$clog2(N)。N 是 2 的幂时这个公式正好N 不是 2 的幂时会有冗余编码比如 5 路独热码需要 3 位二进制而 3 位能表示 8 个值其中 5、6、7 是非法编码。二进制转独热时必须显式处理这些非法值否则移位会越界、比较会落空输出变成全零。是钳位到最后一个合法值、还是报错、还是原样输出全零取决于下游能不能接受但必须是一个明确的决定不能听凭工具推断。反过来独热转二进制时全零输入也是非法状态优先级编码器一般返回 0或树返回 0两者恰好一致但在故障注入测试里这一点要单独验证。3. Verilog 里把两个方向写扎实3.1 二进制转独热的三种写法与综合差异第一种是移位写法最简洁assign onehot {{(N-1){1b0}}, 1b1} bin;这行的含义是构造一个最高位为 1 的 N 位数然后左移 bin 位。综合结果是桶形移位器深度 log2(N) 级。缺点是 bin 超出 N-1 时高位全被移出去结果变成全零正好也是我们期望的非法值行为算是意外之喜。第二种是变量索引写法always (*) begin onehot {N{1b0}}; onehot[bin] 1b1; end注意这里必须先给 onehot 整体赋值 0再改其中一位否则会推断出锁存器。变量索引综合出来是一棵译码树和移位器面积差不多但可读性更好也更容易加保护逻辑。第三种是比较写法genvar i; generate for (i 0; i N; i i 1) begin : gen_dec assign onehot[i] (bin i[DW-1:0]); end endgenerate这条写法最笨但综合结果最干净每个输出独立计算没有共享逻辑时序报告也最好读。位宽上限不受限制N 不是 2 的幂时天然正确处理非法值——所有输出都是 0。我个人的选择是状态数小于 32 用变量索引大于等于 32 且要严格处理非法值用比较写法。3.2 独热转二进制的优先级写法与或树写法优先级写法always (*) begin bin {DW{1b0}}; for (int i 0; i N; i i 1) if (onehot[i]) bin i[DW-1:0]; end这里循环是从 0 往上走后面的赋值会覆盖前面的所以最终保留的是最大的那个置位下标也就是高位优先。想要低位优先就把循环写成从 N-1 递减。这个方向一定要在注释里写死否则接手的人根本看不出来。或树写法always (*) begin bin {DW{1b0}}; for (int i 0; i N; i i 1) if (onehot[i]) bin bin | i[DW-1:0]; end注意这里用的是按位或累加不是赋值。严格独热时它和优先级写法结果相同但综合出的是一棵或树深度是 log2(N)宽位宽下能省掉一大截关键路径。这是我在这两个方向上最推荐的一条优化。3.3 参数化封装与边界保护实际项目里我会把它封成一个模块参数N和DW $clog2(N)两个方向各自一个函数模块名比如enc_conv。二进制转独热加一个bin_valid (bin N)的输出独热转二进制加一个onehot_valid (onehot ! 0) ((onehot (onehot - 1)) 0)这个表达式是判断是否恰好只有一位为 1的经典技巧onehot (onehot-1)会清掉最低位的 1结果为 0 说明只有一位。把合法性判断做成独立输出而不是内部消化测试平台可以直接覆盖综合时如果下游不用这个信号也会被优化掉零成本。function automatic [DW-1:0] oh2bin(input [N-1:0] oh); integer i; begin oh2bin {DW{1b0}}; for (i 0; i N; i i 1) if (oh[i]) oh2bin oh2bin | i[DW-1:0]; end endfunction3.4 仿真用例清单转换逻辑的测试平台不需要复杂但用例要全。我固定跑这几组全零输入、每一位单独置位N 个用例、每一位单独置位加随机一位干扰位、全 1 输入、以及二进制侧的 0、N-1、N、2^k-1 这几个边界值。特别要跑到 N 不是 2 的幂的配置比如 N 等于 12 或 20这类配置最容易暴露位宽错误。仿真里用$clog2而不是手写常数能避免大量低级错误。4. 软件侧的另一套解法4.1 内建函数是最省事的选择在 C/C 里二进制转独热就是一句1u bin唯一要注意的是 bin 超过 31 时要用1ull否则是未定义行为。独热转二进制用 GCC/Clang 的内建函数__builtin_ctz返回末尾 0 的个数正好等于最低置位的下标unsigned int bin2oh(unsigned int bin) { return 1u bin; } int oh2bin(unsigned int oh) { return __builtin_ctz(oh); } int oh2bin_msb(unsigned int oh) { return 31 - __builtin_clz(oh); }ctz和clz在主流平台上会编译成单条指令x86 上是tzcnt/bsf和lzcnt/bsrARM 上是rbit加clz的组合。唯一的坑是输入为 0 时这两个函数的行为在 C 标准里是未定义的GCC 会返回位宽所以调用前必须自己判零。MSVC 上是_BitScanForward和_BitScanReverse通过出参返回结果并用返回值标志是否找到接口形态不同跨平台代码里夹一层宏。4.2 位扩散加 De Bruijn 乘法如果不想依赖编译器内建函数或者要给老编译器做兼容有一个流传很广的技巧先用位扩散把输入变成低位全 1 的掩码再乘一个 De Bruijn 常数取高位查表。static const unsigned char debruijn32[32] { 0, 1, 28, 2, 29, 14, 24, 3, 30, 22, 20, 15, 25, 17, 4, 8, 31, 27, 13, 23, 21, 19, 16, 7, 26, 12, 18, 6, 11, 5, 10, 9 }; int log2_debruijn(unsigned int v) { v | v 1; v | v 2; v | v 4; v | v 8; v | v 16; return debruijn32[(v * 0x077CB531u) 27]; }原理是位扩散之后 v 变成2^(k1)-1的形式乘以 De Bruijn 常数会把一个唯一的 5 位模式推到高 5 位查表就能还原 k。这个实现没有任何分支和循环也没有指令集依赖在任何 32 位平台上都是几十个周期内完成适合用在不允许用内建函数的可移植代码里。4.3 查表粒度怎么选位宽再大一些比如 64 位或者需要在单次操作里批量转换大量数据时纯查表往往是更优的选择。我的经验是单次调用小于 100 万次用内建函数超过这个量级考虑 8 位分段的 256 项查找表每段查一次再拼结果如果数据是位图形式且要频繁做找下一个置位那应该换数据结构用分层的位图加每层缓存而不是硬做转换。查表法的代价是缓存占用一张 256 项的字节表是 256 字节基本能常驻 L1这个代价通常是值得的。5. 实测踩到的坑与排查过程5.1 独热不独热两种写法给出不同答案这是我在仲裁器项目里遇到的最典型的一个。按设计grant 信号同时只会有一位有效但复位释放的那一拍两个请求者的状态机同时输出使能grant 出现了16h0006这种两位为 1 的情况。优先级编码器给出的是 2或树给出的是 3因为 1 和 2 按位或得 3。两个模块各自都不算错但结果不一致下游拿到的通道号对不上。排查过程是从两个模块的输入信号分叉点开始对波形先确认 grant 确实有多位置位再分别把两个转换逻辑的输出打出来对比才定位到是编码方式不一致。修复方式不是改转换逻辑而是在源头做约束grant 生成模块加互斥保护同时把或树写法的输出加了一拍寄存并在寄存器前打上(* keep true *)保证波形上能看到异常值而不是被优化掉。5.2 变量索引漏赋初值引出锁存器第二个坑看起来很低级但非常容易犯。我最初写的是always (*) begin onehot[bin] 1b1; end综合报告里出现了 latch 警告我没在意因为仿真波形全对。问题是当时序逻辑里某个分支没有覆盖所有路径时组合块的输出会保持上一次的值这在仿真里因为初值是 0 而看不出来实际上到板子上会出现一上电就有一路使能莫名打开的现象。修复很简单在赋值前加一句整体清零always (*) begin onehot {N{1b0}}; onehot[bin] 1b1; end教训是组合块里用变量索引写数组必须整体先赋默认值。这个规则在所有涉及变量索引的组合逻辑里都成立不只是转换。5.3 移位溢出与位宽失配第三个坑出在位宽上。当时 N 是 16bin 是 4 位用移位写法没问题。后来有人把 N 改成 12bin 还是 4 位bin 取到 12 到 15 时移位结果全零。这个行为本身合理但下游没有处理全零把全零当成了一句关闭所有通道导致本该报错的非法输入静默通过了。更隐蔽的一种情况是 N 从 16 改成 20N 不是 2 的幂了1 bin里面的位宽推断出了问题构造的常量宽度没有跟着 N 走移位结果的高位被截断。修复方式是所有位宽都用$clog2(N)推导绝不手写常数并且给转换模块加bin_valid输出让下游强制处理非法输入。5.4 毛刺与被当成时钟的译码输出最后一个坑是组合输出的毛刺。独热转二进制是纯组合逻辑输入多位同时变化时输出会出现多个跳变这些跳变虽然稳定值是对的但如果直接把输出当成后续逻辑的时钟使能或者异步复位就会造成误触发。当时我把一个译码输出直接接到了某个寄存器的异步复位端仿真里因为信号都是理想的一切正常上板之后偶尔出现寄存器被误复位排查了很久才发现是毛刺。解决办法是绝不用组合译码结果做时钟或异步控制必须打一拍寄存之后再驱动。6. 位宽大起来之后的资源账和时序账6.1 LUT 级数与关键路径怎么估先把账算清楚。k 位二进制转 N2^k 位独热每个输出是一个 k 输入的函数用四输入 LUT 实现时单个输出的 LUT 级数大约是 ceil(log4(k))k 等于 4 时一级就够k 等于 6 时两级整个译码树的关键路径约等于这个级数与 N 关系不大只和 k 有关。这是二进制转独热在大位宽下依然便宜的原因。独热转二进制就分化了。优先级编码的级数是 N 量级的级联因为每一位都要等前一位32 位时关键路径可能有二三十级这是完全不可接受的。或树转换后是 N 个输入归约成 log2(N) 个输出每个输出要归约 N/2 个独热位用四输入 LUT 做树形归约的级数是 ceil(log4(N/2))32 位独热时大约三级64 位大约四级。这个差距非常大也是我在所有宽位宽场景强制使用或树的原因。位宽 N优先级编码级数或树编码级数二进制转独热级数8约 8约 2约 116约 16约 2 至 3约 1 至 232约 32约 3约 264约 64约 4约 2128约 128约 4 至 5约 3表里的级数是经验估算实际数字取决于综合工具的优化能力和目标器件但量级关系是稳定的。6.2 拆分、流水线与寄存输出当位宽到 128 甚至更大或者频率要求很高、组合深度成了关键路径时有两个标准动作。第一是拆分把 N 位独热码切成若干段每段独立转换出局部结果和该段是否有置位的标志再用一个小优先级电路选段选中的段再细化出段内偏移。这是一个两级结构深度从 O(N) 降到 O(sqrt(N)) 左右分段数在 4 到 8 之间通常最划算。第二是流水线在两级之间插一级寄存器代价是一个周期延迟。我的判断标准是如果这条转换路径的延迟占了时钟周期的四分之一以上就值得考虑插寄存器如果只是偶尔超一点优先调综合策略和约束别急着动结构。另外转换模块的输出如果直接驱动大扇出的信号建议在输出端加一级寄存器这样既能切断组合路径又能保证毛刺不会传下去。6.3 不同场景的选型建议我把这些年用下来的经验整理成一张对照表不同场景直接照着选。场景推荐方向与写法理由FPGA 状态机状态编码独热交给综合工具自动编码触发器充裕下一状态逻辑浅ASIC 状态机状态编码二进制或灰度码面积敏感独热带宽成本高仲裁器 grant 转索引独热转二进制或树写法深度低宽位宽下优势明显软件传通道号开使能二进制转独热移位写法一行搞定配合合法性检查中断号与中断位图互转双向独热转二进制用内建函数软件侧 ctz 就是单条指令位图找下一个置位免转换直接用 ctz 类操作转换反而多一步宽位宽高频路径分段加流水线把深度从线性压到根号级最后分享一个我自己一直在用的小技巧把两个方向的转换都封装成模块输入输出各带一个 valid 信号然后在测试平台里加一段随机激励专门随机生成多位为 1 的非法独热输入和越界的二进制输入跑上十万条。这类 bug 在定向测试里几乎不可能被发现只有随机会诚实地把它们挖出来。我现在的转换模块在没有跑过这段随机测试之前是不允许合入主分支的。
返回列表