尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

手写IEEE 754单精度浮点乘法器:Verilog实现与验证

手写IEEE 754单精度浮点乘法器:Verilog实现与验证 做数字前端这行的朋友迟早会在项目里撞上浮点运算。无论是FPGA里的AI加速、视频处理还是ASIC里的数学单元乘法永远是最先需要解决的问题。IEEE 754单精度浮点乘法器看着简单——不就是两个32位数相乘嘛但真到了Verilog实现阶码偏置、尾数规格化、舍入进位、非规格化数每一步都能把你按在地上摩擦。这篇博文我打算把我调试过的一版完整实现拆给你看从格式原理一路写到可综合的Verilog代码再讲清楚验证方法最后把我在仿真和时序优化里踩过的坑也一并交代清楚。整个工程在常见FPGA开发板上可以直接跑也能作为数字IC面试准备的练习项目尤其适合刚接触浮点数据通路的同学。1. 这个乘法器到底在做什么为什么值得手写一遍市面上的FPGA自带浮点IPVivado里拖一个floating-point核配置一下就有现成的乘法器ASIC流程里也有DesignWare可以用。但你如果只是这样用就永远不知道浮点乘法器内部到底是什么样的数据通路。我在带新人的时候经常说一句话浮点加法器是练级浮点乘法器是入门副本浮点除法器才是深渊。因为浮点乘法本质上比加法简单没有对阶、没有大数吃小数的问题核心就是“符号异或、指数相加、尾数相乘”三件事但它把数值编码、边界情况、舍入策略这些东西全部浓缩在一个不大的模块里非常适合作为学习浮点运算的切入口。从实际应用场景来说我自己在FPGA上做过卷积加速器里面大量用到定点转浮点、浮点乘加。硬件乘法器直接复用DSP块当然快但一旦你需要定制精度、需要FMA融合乘加、需要低功耗或面积优化版本就必须自己动手改数据通路。如果你连标准单精度乘法器都写不利索后面做半精度、做非规格化、做乘法累加都是空谈。这篇文章给到的完整代码是组合逻辑版本latency接近0逻辑清晰适合仿真和理解工程中如果频率要求高可以按我第5.2节给出的方法改造成流水线。1.1 目标与参考价值这版实现的输入是两个32位单精度浮点数输出一个32位单精度浮点数。功能上覆盖了规格化数相乘、正负零、正负无穷、NaN检测、0乘无穷的非法操作以及溢出、下溢的饱和处理。这里我明确说一下取舍对于非规格化数subnormal我采用的是flush-to-zero策略也就是只要输入出现非规格化数直接按零处理输出。原因我在后面会详细讲因为完整支持非规格化数需要把指数运算改成有符号比较逻辑量几乎翻倍而很多高速浮点单元出于面积和时序考虑本身就选择不硬件支持非规格化数交给软件层去处理。所以如果你是想学标准IEEE 754完整语义这版代码可以当作骨架如果你直接拿去做算法加速flush-to-zero在很多音频、图像处理场景里完全够用。整个模块只用一个always块完成所有组合逻辑没有跨时钟域、没有状态机读起来不会劝退。建议的阅读顺序是先第2节搞懂原理再对第3节代码然后自己动手写testbench跑第4节的用例最后再考虑要不要改流水线。1.2 设计取舍组合逻辑还是流水线市面上很多教学代码喜欢一上来就写多级流水接口带valid和ready然后对着时序图讲一堆。我觉得对于学习目的来说这是本末倒置因为你的核心难点是数值处理逻辑不是握手信号。这一版我选择纯组合逻辑输出仿真时给上a和bresult立刻就能出来方便你用$display打印中间量也方便单步调试。等逻辑完全调通了再按照我第5.2节的方法插入流水寄存器反而比一开始就写带流水的代码要稳妥得多。另外还有一个重要原因是组合逻辑版本和后面的随机验证配合起来非常顺。因为没有时序你在testbench里可以一个时钟周期内直接比较期望值和实际值不用去考虑latency。缺点是组合路径长尤其24bit乘24bit在FPGA上很容易成为关键路径但这不耽误理解算法。如果你做ASIC综合工具会自动插入retiming帮你挪寄存器如果你做FPGA切流水线的操作也很机械。所以我一直认为把核心算法写成组合逻辑是浮点模块开发里性价比最高的起点。2. 原理复盘IEEE 754 格式与乘法规则单精度浮点数总共32位分成三块最高位是符号位然后8位指数最后23位尾数。它的数值公式是((-1)^{sign} \times 1.fraction \times 2^{exp-127})其中指数域存储的是偏置后的值真实指数要减掉127。这个“隐藏的1”是规格化数最核心的约定因为规格化数的整数部分一定是1所以硬件不需要显式存储它只需要在取尾数计算时把它拼回去。对应到Verilog就是man_a {1b1, frac_a}这样实际参加乘法的是一个24bit的数两个24bit相乘得到48bit结果。关于特殊值的编码我整理成了下表这是写代码前必须刻在脑子里的东西指数域尾数域含义常见十六进制示例00有符号零0x00000000 / 0x800000000非0非规格化数0x000000011 ~ 254任意规格化数1.0 0x3F8000002550有符号无穷0x7F800000 / 0xFF800000255非0NaN非数0x7FC00000有了这个表乘法器的第一层判断逻辑就清晰了如果出现NaN直接输出NaN如果0乘无穷IEEE规定结果是NaN如果无穷乘非零规格化数结果是无穷如果任意输入是零结果是零符号位照常异或。这些分支的优先级必须安排好否则就会出现Inf×0返回±Inf而不是NaN的错误。2.1 单精度格式和特殊值编码为什么指数要偏置127而不是直接用补码因为这样浮点数可以用整数方式比较大小快速排序、快速查表都很方便。不过这个偏置给指数加法带来了一个小坑两个指数相加后要减去一次127否则偏置就重复加了。比如1.0的指数域是1272.0的指数域是128它们相乘的真实指数应该是127128-127128对应的数是1.0×2^{128-127}2.0不对1.0×2.02.0指数域为128是对的。这里公式要特别小心我用的是真值(exp_{result}exp_aexp_b-127)然后再根据尾数乘法的规格化情况决定是否再加1。在Verilog实现里指数相加不能只定义8位寄存器因为两个指数域最大值254254508这个和要放在更高位宽里再减偏置。更关键的是当两个很小的规格化数相乘时减完偏置后指数可能是负数这种下溢情况必须用有符号数判断。我在最初版本里就吃过亏把中间结果定义成8位无符号结果小于1的数相乘最后会得到一个诡异的正指数调试半天才发现是位宽不够后面给大家看的代码里统一用signed [9:0]类型来保存这个中间值。2.2 乘法手算拆解以1.5 × 2.5为例光讲公式太空我们直接手算一个例子。1.5的十六进制表示是0x3FC00000二进制展开是0 01111111 10000000000000000000000指数域127尾数域0x400000隐藏位补上后24位尾数是0xC00000。2.5的十六进制是0x40200000指数域128尾数域0x20000024位尾数是0xA00000。两个24位尾数相乘0xC00000 × 0xA00000 0x7800000000这是一个48位数。关键看它的最高位prod[47]这里最高位是0说明乘积的真实值落在[1,2)区间不用右移规格化指数保持exp_biased127128-127128。尾数域取prod[45:23]得到0x700000。最后拼出0x40700000十进制就是3.75完全正确。如果prod[47]是1说明乘积落在[2,4)区间需要右移一位并把指数加1这就是规格化数乘法里唯一的“特殊操作”。3. 代码架构与完整实现这一节直接进入Verilog实现。我会把代码拆成输入识别、指数尾数处理、规格化舍入三部分来讲每一部分给出片段和解释最后在3.4节给出一个可以直接复制综合的完整模块。整体思路是用一个组合逻辑always块完成所有计算不使用时钟因此也没有复位和有效信号方便仿真单步观察。3.1 接口与输入识别模块接口非常朴素两个32位输入一个32位输出。内部先把输入拆成符号、指数、尾数三组线然后定义输入是否为零、无穷、NaN、非规格化数。这里要养成一个习惯所有判断都用wire提前算好不要在always里重复写判断条件既容易出错也影响可读性。module fp32_mul_core ( input wire [31:0] a, input wire [31:0] b, output reg [31:0] result ); wire sign_a a[31]; wire sign_b b[31]; wire [7:0] exp_a a[30:23]; wire [7:0] exp_b b[30:23]; wire [22:0] frac_a a[22:0]; wire [22:0] frac_b b[22:0]; wire a_zero (exp_a 8d0) (frac_a 23d0); wire b_zero (exp_b 8d0) (frac_b 23d0); wire a_sub (exp_a 8d0) (frac_a ! 23d0); wire b_sub (exp_b 8d0) (frac_b ! 23d0); wire a_inf (exp_a 8d255) (frac_a 23d0); wire b_inf (exp_b 8d255) (frac_b 23d0); wire a_nan (exp_a 8d255) (frac_a ! 23d0); wire b_nan (exp_b 8d255) (frac_b ! 23d0);特殊值判断的正确性是整个模块的地基。你需要特别关注的是a_inf b_zero这种情况也就是0乘无穷IEEE标准规定结果必须是NaN而不是±Inf或±0。很多初版代码容易把无穷判断写死在零判断前面导致这种非法操作被错误地输出成无穷这个问题在面试里也经常被拿来当陷阱题问。3.2 尾数求积、指数相加与前置处理接下来是核心运算部分。尾数乘法前先拼上隐藏位非规格化输入在这里统一处理成0man_a (exp_a 8d0) ? 24d0 : {1b1, frac_a}。因为前面的分支已经把zero和subnormal都拦截掉了正常进入乘法分支的一定是规格化数man_a必然是24bit的1.frac格式。两个24bit数相乘得到48bit的无符号积指数部分用一个有符号10bit变量来保存exp_a exp_b - 127的结果。wire [23:0] man_a (exp_a 8d0) ? 24h0 : {1b1, frac_a}; wire [23:0] man_b (exp_b 8d0) ? 24h0 : {1b1, frac_b}; reg [47:0] prod; reg signed [9:0] exp_biased;这里我故意把prod和exp_biased声明成reg原因是它们需要在always里被赋值。exp_biased的类型是整个模块最容易写错的地方必须用有符号数。直接用8位wire做exp_a exp_b - 8d127的话当两个最小规格化数相乘时真实指数是-125硬件上会变成一个大正数最后输出的不是零而是一个错误的大数。调试这种问题非常痛苦因为结果看起来“像模像样”但数值上完全不对。3.3 规格化、舍入与溢出判断拿到48bit乘积后按prod[47]判断结果是否大于等于2。如果prod[47]1说明真实的数字范围是[2,4)此时右移一位规格化指数加1否则指数不变。取尾数时我额外留下了一位guard bit和sticky bit参与舍入判断这是保证精度不出现系统性偏差的关键。舍入算法我采用的是最常见的round-to-nearest-even也就是“四舍六入五成双”当被丢弃的最高位是0直接截断当guard bit是1且低位还有剩余的sticky为1向上进位当guard bit是1且低位全部为0也就是结果刚好落在两个可表示数正中间时看保留的尾数最低位是奇数还是偶数奇数则进位偶数则截断。用Verilog写出来并不复杂关键在于处理进位后尾数为全0的情况此时需要把尾数清零并把指数加1否则会出现“尾数加了半天指数没动”的bug。reg [22:0] frac_rounded; reg exp_inc; // ... if (guard_bit (sticky_bit || frac_d[0])) begin if (frac_d) begin frac_rounded 23h0; exp_inc 1b1; end else begin frac_rounded frac_d 23h1; exp_inc 1b0; end end else begin frac_rounded frac_d; exp_inc 1b0; end指数最后还要做一次溢出和下溢判断如果exp_unrounded exp_inc 255返回无穷如果 0按flush-to-zero返回零。这里的难点是“指数中间结果”必须用有符号比较不能等到拼完最后一个32bit后再看。我见过很多实现是直接把exp_d exp_inc塞到8bit位域里然后丢给上层判断这种写法在负数变正数的场景下会直接翻车。3.4 完整模块代码可直接综合把上面所有片段整合成一个文件下面这部分我全部贴出来。代码里我保留了详细注释你在Vivado、Quartus或者ICARUS Verilog里直接编译就行不需要额外依赖。module fp32_mul_core ( input wire [31:0] a, input wire [31:0] b, output reg [31:0] result ); // 拆位 wire sign_a a[31]; wire sign_b b[31]; wire [7:0] exp_a a[30:23]; wire [7:0] exp_b b[30:23]; wire [22:0] frac_a a[22:0]; wire [22:0] frac_b b[22:0]; // 特殊输入识别 wire a_zero (exp_a 8h00) (frac_a 23h0); wire b_zero (exp_b 8h00) (frac_b 23h0); wire a_sub (exp_a 8h00) (frac_a ! 23h0); wire b_sub (exp_b 8h00) (frac_b ! 23h0); wire a_inf (exp_a 8hff) (frac_a 23h0); wire b_inf (exp_b 8hff) (frac_b 23h0); wire a_nan (exp_a 8hff) (frac_a ! 23h0); wire b_nan (exp_b 8hff) (frac_b ! 23h0); wire sign_res_w sign_a ^ sign_b; // 拼上隐藏位subnormal与zero统一当作0处理flush-to-zero wire [23:0] man_a (exp_a 8h00) ? 24h0 : {1b1, frac_a}; wire [23:0] man_b (exp_b 8h00) ? 24h0 : {1b1, frac_b}; reg [47:0] prod; reg signed [9:0] exp_biased; reg signed [9:0] exp_unrounded; reg [22:0] frac_d; reg guard_bit; reg sticky_bit; reg [22:0] frac_rounded; reg exp_inc; always (*) begin // 准备中间量 prod man_a * man_b; exp_biased $signed({2b0, exp_a}) $signed({2b0, exp_b}) - 10sd127; // 默认输出避免产生latch result 32h0; if (a_nan || b_nan) begin // 任一输入为NaN输出NaN result {sign_res_w, 8hff, 23h400000}; end else if ((a_inf b_zero) || (a_zero b_inf)) begin // 0 * Inf 是非法操作输出NaN result {sign_res_w, 8hff, 23h400000}; end else if (a_inf || b_inf) begin // 规格化数与无穷相乘结果是无穷 result {sign_res_w, 8hff, 23h0}; end else if (a_sub || b_sub || a_zero || b_zero) begin // flush-to-zero遇到非规格化数或零直接输出零 result {sign_res_w, 8h00, 23h0}; end else begin // 规格化数相乘 if (prod[47]) begin exp_unrounded exp_biased 10sd1; frac_d prod[46:24]; guard_bit prod[23]; sticky_bit |prod[22:0]; end else begin exp_unrounded exp_biased; frac_d prod[45:23]; guard_bit prod[22]; sticky_bit |prod[21:0]; end // 先判断指数是否已经上下溢避免对无效尾数继续做舍入 if (exp_unrounded 10sd0) begin result {sign_res_w, 8h00, 23h0}; end else if (exp_unrounded 10sd255) begin result {sign_res_w, 8hff, 23h0}; end else begin // round to nearest even if (guard_bit (sticky_bit || frac_d[0])) begin if (frac_d) begin frac_rounded 23h0; exp_inc 1b1; end else begin frac_rounded frac_d 23h1; exp_inc 1b0; end end else begin frac_rounded frac_d; exp_inc 1b0; end // 舍入进位后的二次溢出判断 if (exp_unrounded exp_inc 10sd255) begin result {sign_res_w, 8hff, 23h0}; end else if (exp_unrounded exp_inc 10sd0) begin result {sign_res_w, 8h00, 23h0}; end else begin result {sign_res_w, exp_unrounded[7:0] exp_inc, frac_rounded}; end end end end endmodule综合时直接把a、b当作32bit输入编译器能识别出你是要做一个乘法器会尝试推断DSP块。如果你的目标是ASIC这段代码交给综合工具后会自动映射到标准单元乘法器不需要额外改写法。man_a * man_b这种写法在FPGA上会被映射成DSP的18×18乘法器拆分组合如果不想用DSP可以加(* use_dsp no *)属性强制用LUT实现但面积会大不少。4. 验证策略从定向用例到随机对比写完了代码坑才刚开始。我自己调试浮点模块的经验是先人工构造一批“手算都能算出来”的用例把正确结果直接写死在testbench里然后跑随机向量用参考模型去比对最后再统计特殊值覆盖情况。不要上来就写随机测试因为数据位一旦错乱你根本分不清是参考模型的问题还是代码的问题。4.1 定向测试用例设计我常用的定向用例表放在下面覆盖了所有特殊值分支和几个典型的规格化数运算。注意最后两行一个测下溢一个测上溢这是最容易漏测的边界情况。输入A输入B期望输出说明0x000000000x3F8000000x000000000 × 1 00x3F8000000x3F8000000x3F8000001 × 1 10x3FC000000x402000000x407000001.5 × 2.5 3.750xC00000000x404000000xC0C00000-2 × 3 -60x7F8000000x3F8000000x7F800000Inf × 1 Inf0x7FC000000x3F8000000x7FC00000NaN × 1 NaN0x7F8000000x000000000x7FC00000Inf × 0 NaN0x000000010x3F8000000x00000000subnormal flush到00x7F7FFFFF0x400000000x7F800000最大有限数 × 2 Inf前四行验证的是核心乘法路径第五到第七行验证的是特殊值优先级第八行验证flush-to-zero策略第九行验证上溢饱和。把这些用例跑过后至少能证明“主干道是通的”。接下去再往testbench里补一些更细的边界比如0x80000000负零乘正数期望输出还是要看符号位异或后的结果不要想当然以为负零乘正数就是正零。4.2 随机测试与参考模型比对定向用例过了不代表实现正确因为尾数乘法进位的场景可能没测到。最有效的方法是用SystemVerilog写一个随机测试task生成大量规格化输入用$bitstoreal转成real做参考计算。但这里有一个非常重要的坑real是双精度浮点数双精度和单精度的舍入行为不完全一致。双精度先对无限精确的乘积做一次舍入再转成单精度时又做一次舍入这个“双重舍入”在某些边界值上会让你拿到的参考结果和“单精度精确舍入”差1个ulp。所以我的建议是随机测试里不要做位精确比较把允许误差放宽到1~2个ulp。如果你确实需要位精确的参考模型那就得在仿真里自己用整数运算模拟一遍舍入过程等于再写一个参考乘法器这个工作量在项目后期如果对覆盖率要求很高再考虑。下面是我常用的随机测试伪代码task automatic random_check(); bit [31:0] va, vb, got, exp; for (int i 0; i 10000; i) begin // 生成规格化数指数域限制在1~254尾数域随机 va {1b0, $urandom_range(1, 254), $urandom}; vb {1b0, $urandom_range(1, 254), $urandom}; // 喂给DUT dut.a va; dut.b vb; #1; got dut.result; // 参考模型用real计算 real ra $bitstoreal(va); real rb $bitstoreal(vb); real rm ra * rb; exp $realtobits(rm); // 比较时允许1ulp误差具体实现可以把两个结果转成整数计算差值 end endtask注意生成随机数时不要直接用$random塞满32bit因为那样会有接近40%的概率生成特殊值核心路径反而测不到。把指数域约束在1到254尾数域完全随机这样生成出来的基本都是规格化数随机测试的效率会高得多。4.3 覆盖哪些边界才算靠谱做浮点模块我一般会看四类覆盖率特殊值组合覆盖、指数边界覆盖、尾数进位覆盖、舍入模式覆盖。特殊值组合最简单把0、Inf、NaN、一般规格化数两两相乘看输出是否落在合法性表格里。指数边界要专门生成指数为1和254的数去互相乘验证下溢和上溢。尾数进位主要关注frac接近全1的数比如0x7F7FFFFF乘以1.001这种看是否触发舍入后的指数加一。舍入模式则要构造恰好落在tie上的数比如尾数最低位为0或1时分别检查“五成双”行为。这些边界用例人工构造起来有点繁琐但非常值得。我见过不少模块在普通随机测试下跑得飞起一到极端输入就挂。浮点数据通路的可怕之处在于错误往往是1个ulp的偏差在大的数值计算里可能被淹没但在某些迭代算法里会不断被放大。把这些边界用例沉淀成回归测试列表以后每次改动模块都能跑一遍。5. 常见问题与工程优化实录5.1 仿真阶段最容易踩的坑第一个高频问题就是x态。组合逻辑模块没有复位仿真一开始result如果是x先检查是不是always块里漏了默认赋值。我的代码在最前面就写了result 32h0;保证所有分支都不会因为未赋值而保持旧值也不会有latch。如果你把输出信号接到了某些IP核的复位域仿真初期出现x也非常正常可以先检查testbench里有没有给a、b一个确定的初值。第二个坑是“看起来对实际错”的指数位宽问题。我在第3.2节强调过中间指数必须用signed否则最小规格化数相乘时结果会被截成错误的大正数。这种bug最讨厌的地方是定向用例如果全挑大数根本测不出来。我的做法是在testbench里专门加一组“小指数×小指数”的用例比如1.0×1.0再检查指数域是否精确等于127。第三个坑是特殊值分支的顺序。分支顺序写错时输入Inf×0会得到Inf而不是NaN而且这种错误在回归测试里不会立刻暴露因为大多数随机测试生成不到这种组合。我建议你把特殊值判断集中到一个独立的if-else链里保持“NaN 0×Inf Inf Zero/Subnormal 规格化乘法”的顺序注释也写清楚避免后面维护的人改坏。5.2 时序不满足时的流水线改造组合逻辑版本在FPGA上跑到100MHz左右问题不大但如果目标频率到200MHz以上24bit×24bit乘法器加舍入逻辑就会成为关键路径。流水线改造的基本思路是第一级寄存符号、指数加法结果和48bit乘积第二级做规格化和舍入。切分点在prod后面最自然因为乘法器的延迟基本占了整个组合路径的一大半。改造后的接口需要加clk和valid_in、valid_out输出延迟会变成两个周期。如果你对延迟敏感也可以只插一级寄存器在最后虽然关键路径还是包含乘法器但至少舍入逻辑不再和乘法器串在一起。用FPGA自带DSP时还有个技巧DSP本身自带流水级你可以把第一级寄存器直接挪进DSP的寄存器综合工具一般会自动推断不需要手动改代码。这里我给不了通用配置因为不同FPGA家族的DSP结构不一样但核心思路就是“把蛋糕切薄”。5.3 资源与面积的选择如果你用man_a * man_b这种写法FPGA综合器会尽可能映射到DSP块ASIC综合器会映射到标准乘法器单元这是最高效的。但有些场景你不希望用DSP比如DSP块要留给更复杂的算子这时可以改成“按部分积累加”的写法即用移位加实现乘法面积增大但资源分配更自由。这种手工乘法器在面试题里很流行比如用Wallace树或进位保存加法器但实际工程里除非你对乘法器的微架构有明确需求否则没有必要手撕。舍入策略也影响面积。round-to-nearest-even需要计算sticky bit并做奇偶判断这会让尾数路径多一个加法器。如果你们系统可以容忍截断舍入也就是直接把guard bit之后全部丢掉逻辑面积能省不少。我做过一个音频项目那里面对精度要求没那么高直接用截断模式面积和时序都舒服很多。所以不要盲目追求“完全符合IEEE 754”先看系统的容错能力再决定要不要实现完整舍入。关于非规格化数的支持工程上我建议先做成flush-to-zero等跑通后再评估要不要升级。完整支持subnormal需要把指数运算改为有符号范围判断并且要把man_a的隐藏位改成0开头运算逻辑会更复杂面积和时序都有代价。IEEE 754标准允许实现选择不支持subnormal计算许多商用浮点单元也是靠软件兜底。最后说点我自己的体会。这个项目看着是“乘法器”其实真正练的是三件事读懂编码规范、安排好特殊值分支、把关键路径切干净。第一版我实现完直接拿随机数去砸发现一大半结果对不上后来才意识到是参考模型本身带了double rounding误差这个坑写过才知道。如果你也是刚学浮点建议先跑通1.5×2.5这种手工用例再逐个补特殊值最后再上随机验证。下一步可以试着把它改成两级流水或者干脆做一个乘加单元FMA——那才算真正把这条数据通路吃透了。
返回列表