ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Verilog数字表示详解:定点数与浮点数的运算实践

Verilog数字表示详解:定点数与浮点数的运算实践 做FPGA或者ASIC的朋友十有八九会被数字表示问题绕晕。verilog中的定点数、浮点数、定点小数、定点整数其实问的是同一件事——怎么用一根根bit表示我们脑子里熟悉的数学数字并且让加减乘除在硬件上不出错。这篇东西想解决的就是这件事我会从定点整数讲到定点小数再从IEEE754浮点到四种数字的互相转换和运算最后给出一份可以直接拿去用的Q格式运算模块。我见过太多人在这上面翻车仿真波形里看着是个负数拿到硬件上就变成了一个大正数乘法器输出位宽没留够一个“简单的乘法”把整个信号链都截坏了最离谱的是有人直接拿real类型写可综合模块结果综合器报“not synthesizable”还一脸懵。所以我今天把这些年踩过的坑、整理好的规则、验证过的代码全摊开讲一遍后面做数据处理、滤波算法、通信基带和控制器设计的时候你会回来感谢这篇文的。1. 为什么Verilog里数值表示是个老大难问题1.1 硬件描述语言的本质比特没有“类型”和C语言、Python不一样Verilog里的reg [7:0] a不会告诉综合工具“a是个整数”还是“a是个小数”。这条8位总线里面存的就是8个电平状态至于它是0~255、-128~127、还是一个小数部分占4位的Q4.4数完全是设计者自己说了算。这种“无类型”特性是硬件描述语言的核心哲学FPGA里没有“操作系统”替你做类型转换你铺下去的每一根导线、每一个触发器都是物理存在的。你在代码里写assign y a b综合工具看到的是一个加法器你写y a * b综合工具看到的是一个由LUT和DSP切片组成的乘法器。至于加法器出来的结果怎么解释那是你后续逻辑和外部接口的责任。这个特性带来的结果就是你需要自己建立一套“定标scaling”约定并且在代码里通过位宽、有符号属性、移位操作把这种约定固定下来。定点整数、定点小数、浮点数本质上就是三种不同的定标约定。定点整数是“隐藏的小数点在最右边”定点小数是“隐藏的小数点在某个固定位置”浮点数是“每个数都带上自己的比例因子”。1.2 数字表示在FPGA/ASIC设计里的影响范围数字表示方式不是只在某个角落有用它牵涉到整个数字信号处理链路接口设计ADC采样数据通常是无符号二进制补码或偏移二进制DAC输出可能需要定点整数你和外部芯片打交道时每一bit的定义都必须和协议对齐。算法实现PID控制器、FIR滤波器、FFT、CORDIC这些算法在数学上都是实数运算落到硬件上几乎全部转为定点数。只有少数特殊场景才用浮点IP核。资源与性能定点数运算占用的DSP slice、LUT和寄存器远少于浮点运算。一个单精度浮点加法器在FPGA上可能要几百个LUT而同样带宽的定点加法器可能只消耗几十个LUT。延迟也从几拍直接缩到1拍。精度与动态范围浮点数适合动态范围极大、精度要求不统一的场景比如科学计算但代价是硬件复杂度和延迟。定点数在动态范围有限时精度可控硬件实现简单是绝大多数实时信号处理的首选。换句话说数字表示选得对不对直接决定了你的设计能不能在目标时钟频率下跑起来、能不能塞进选定的FPGA芯片、能不能满足端到端的误差指标。这也是为什么很多IC公司面试时特别喜欢考定点数和浮点数因为这真是基本功中的基本功。2. 定点整数从reg到signed先把“整数”搞明白2.1 无符号整数表示位宽、数值范围无符号整数是最简单的情况。一个reg [N-1:0]能表示的数值范围是0到2^N-1这不用多说。但有几个细节新手经常忽略位宽由需求决定不是想写多宽就多宽。你要存0~1000的数10位就够了因为2^101024能覆盖0~1023。如果你只写9位最大只有511硬件不会自动报错而是悄悄溢出——这是定点数设计里最危险的无声错误。无符号数在做减法时如果减数大于被减数结果会是补码形式的大数。比如4b0001 - 4b0010在Verilog中结果为1111如果把它当作无符号数就是15这就错了。所以遇到需要负数的场合千万不要继续用无符号数硬扛直接切到有符号数否则后面做比较、做累加时到处都是坑。2.2 有符号整数二进制补码以及Verilog里的signed/signing有符号数在数字电路里几乎全部使用二进制补码表示。原因很朴素补码让加法和减法使用同一套硬件A - B可以看作A (~B 1)这样电路里只需要加法器。Verilog里表示有符号整数有几种方式// 方式1直接声明signed reg/wire reg signed [7:0] a; wire signed [15:0] b; // 方式2使用integer类型32位有符号数常用于仿真循环 integer i; // 方式3在表达式中临时使用$signed()转换 wire [7:0] a_unsign; wire signed [7:0] a_sign; assign a_sign $signed(a_unsign);这里最容易被坑的是隐式符号位扩展。当两个操作数一个有符号、一个无符号时Verilog会“好心”地全部按无符号处理导致很多莫名其妙的结果。我建议的规则是所有参与运算的变量尽量统一声明为signed总线接口要用无符号时在边界处做显式转换而不是混着算。举个例子reg signed [7:0] a -8sd5; // -5 reg [7:0] b 8d200; // 200 wire signed [15:0] sum1 a $signed(b); // 得到195正确 wire [15:0] sum2 a b; // 因为b无符号a也被当成无符号251200451你期望的-5200195就没了这种混用问题在复杂模块里极难排查因为单独看波形时数值“看起来是有规律地变化”但实际上整个算法已经偏了。2.3 定点整数的运算加减乘除与位宽扩展先说位宽这是所有运算的基础。我的经验公式很简单两个N位整数相加结果位宽为N1位因为可能要进位。两个N位整数相减结果位宽同样需要N1位防止溢出。两个N位整数相乘结果位宽为2N位。除法比较复杂定点除法可以看作是“左移后除法”后面小节细讲。这里有一个实际工程里很常见的场景累加器。比如你要做数字滤波连续累加16个8位采样值最大可能值是256*16-14095需要13位。但很多人直接写一个reg [15:0] acc虽然不会溢出但后面再做乘法或加法时位宽一路膨胀最后资源消耗暴涨。较好的做法是先做定点化分析算清楚最大中间值用刚好够且留一定余量的位宽。如果实在担心溢出还有一个常用手段是“饱和截位”把超出范围的结果钳到最大值或最小值reg signed [15:0] acc; wire signed [7:0] acc_sat; assign acc_sat (acc 127) ? 8sd127 : (acc -128) ? -8sd128 : acc[7:0];这种方式在控制环路里特别有用防止积分饱和导致系统失控。3. 定点小数Q格式与硬件小数运算3.1 为什么硬件里不用浮点而用“定标”我们先看一个简单问题0.1 0.2在C语言里可能得到0.30000000000000004在FPGA里如果用浮点IP核一样会有舍入误差和几十拍的延迟。而控制系统、音频处理、电机控制这类场景信号的动态范围通常在几个数量级内根本不需要浮点那套动态指数。这时“定点小数”就上场了。它把小数部分固定在某几个bit上称为“定标”。比如一个16位数高8位表示整数低8位表示小数这个数就是Q8.8格式。你心里知道第8位是2^-1第9位是2^-2以此类推。硬件做加法时根本不需要知道小数点在哪加上去自然就对齐了做乘法时则需要额外处理位宽和移位这是定点运算的核心技巧。3.2 Q格式定义Qm.n符号位、整数位、小数位Q格式的标记法有多种最常见的写法是Qm.n其中m表示整数位不含符号位n表示小数位总位宽 1符号位 m n如果是有符号数。例如一个16位有符号数Q1.14表示1位符号位、1位整数位、14位小数位动态范围约[-2, 2)分辨率约2^-14 0.000061035。Q8.8表示8位整数、8位小数动态范围约[-128, 128)分辨率约0.00390625。Q0.15表示纯小数范围约[-1, 1)分辨率约0.0000305176。选择Q格式就是选择“动态范围 vs 精度”的平衡。你需要的最大信号幅度决定了整数位个数需要的最小分辨率决定了小数位个数。比如电机电流采样范围是[-10A, 10A]分辨率要0.001A那么整数位至少4位2^38不够2^416够小数位至少10位2^-10≈0.000976有符号数总共需要141015位就可以取Q4.10或Q4.11作为设计目标。3.3 定点小数的加法、乘法、除法运算规则加法两个定点小数相加必须小数位对齐。只要格式相同直接用普通加法器相加即可。如果格式不同比如Q8.8加Q4.12要先统一到一个格式通常是移位加上扩展符号位。这个统一过程必须在代码中显式写好不能指望工具自动做。乘法两个定点小数相乘结果的小数位数是两者小数位数之和。比如两个Q4.12数相乘结果的小数位数是24位。但实际硬件里我们往往只需要保留16位结果所以要截掉低8位或低12位并且注意四舍五入。除法除法可以看作是先将被除数左移n位再做整数除法商的小数位就是n位。比如要计算a / b其中a是Q4.12格式b也是Q4.12结果想用Q4.12表示那可以直接(a 12) / b。这里的逻辑是a的真实值是A a * 2^-12b的真实值是B b * 2^-12A/B a/b。但用整数除法a/b得到的是整数精度所以先把a左移12位变成a*2^12再除以b结果就是a/b*2^12也就是Q4.12格式的结果。除法在FPGA里开销大通常的做法是用查找表、CORDIC或迭代逼近算法实现而不是直接用/。不过如果你的时钟频率不高、数据率不高直接用/让综合工具生成除法器也不是不行只是要关注资源和时序。3.4 定点小数的格式化与饱和/截位定点格式转换是最容易出错的环节。转换的本质就是“移动小数点的位置”对应到硬件就是左移或右移。从Q4.12转到Q8.8需要右移4位。右移时应当保留符号位Verilog对有符号数的右移是算术右移所以直接a 4是对的。从Q8.8转到Q4.12需要左移4位。左移后低4位补0这时分辨率提高了但动态范围变窄可能溢出需要先做饱和判断。截位时不要只做简单截断最好做“四舍五入”。最简单的四舍五入是截位前加上舍入位即(a (1 (n-1))) n对有符号数同样适用不过要注意负数边界。我一般会在模块里专门写一个fn_resize_sat函数SystemVerilog里用function统一处理饱和和舍入避免散落在各处造成混乱。在实际项目中这种“一处统一、处处调用”的方式能少二分之一的bug。4. 浮点数IEEE 754与Verilog实现4.1 浮点数格式总览单精度、双精度IEEE 754单精度浮点数用32位表示分为三段1位符号位signS8位指数位exponentE采用偏移量12723位尾数位fractionF隐含整数位1于是单精度浮点数的值为(-1)^S * 1.F * 2^(E-127)。双精度则是1位符号、11位指数偏移量1023、52位尾数。这里请一定记住浮点数不是“精确”数字而是“动态范围大但精度有限”的近似表示。比如0.1在单精度里并不是整好的0.1而是一个二进制近似值。这种特性会让硬件实现变得复杂也让你在做相等判断时非常危险。4.2 浮点数的规格化与指数偏移规格化normalization是浮点运算里最核心的一步让尾数的最高有效位为1。比如二进制数1101.0011写成规格化形式就是1.1010011 * 2^3尾数的隐藏整数位就是1。在IEEE 754里这个1不存储称为“隐含位”。指数偏移bias的作用是把正负指数映射成无符号整数方便比较大小。单精度的指数范围是-126到127存储时加上127变成1到254。0指数和255指数有特殊含义0、非规格化数、无穷、NaN不过一般工程里用到的情况较少。理解了规格化和指数偏移你就能看懂浮点加法器为什么比定点加法器复杂得多加之前要对齐指数把小数点位对齐加完之后要重新规格化还要处理舍入。这每一段都是一套硬件逻辑。4.3 在Verilog中做浮点运算的方式IP核、手写模块、仿真行为级实际做FPGA项目时用浮点有几种选择我按推荐程度排序使用厂商浮点IP核。Xilinx有Floating-Point IP核Intel有ALTFP这些IP核经过了优化支持单/双精度、各种运算和舍入模式。缺点是要配置复杂参数延迟和资源也由IP核决定不够灵活。使用SystemVerilog的real类型进行行为建模。real类型在仿真中可以使用但不能综合。如果你只是需要做算法验证、写testbench用real最方便。我经常在testbench里用real计算期望结果再和RTL输出的定点数转浮点后对比。自己手写浮点运算模块。这是很多IC面试题和进阶项目喜欢考的内容。手写一个浮点加法器会让你对规格化、舍入、对阶有深刻理解但量产工程里很少为了省IP授权去手写除非是自定义浮点格式。4.4 一个简单的浮点加法器设计思路伪代码级我以单精度浮点加法器为例用伪代码流程描述一下结构方便你理解原理输入a(f32), b(f32) 步骤1拆解符号、指数、尾数还原隐含位1.F得到24位尾数 步骤2比较指数小的那个尾数右移(指数差)位完成对阶 步骤3根据符号位决定相加还是相减 步骤4统计结果前导零个数左移规格化同时调整指数 步骤5按舍入模式做舍入可能需要再次规格化 步骤6组装符号、指数、尾数输出结果每步都涉及位宽和边界判断比如指数差大于24时直接用小者因为右移24位后尾数完全掉出去了。这个设计在Verilog里大约几百行综合后延迟通常在10拍以内。但如果你只是工程应用我还是推荐直接调IP核。4.5 浮点数的风险精度、舍入、延迟和资源在项目里面使用浮点IP核心里要有几本账精度账单精度只有23位尾数大约7位十进制有效数字。做长时间累加的时候小数字可能被大数字“吃掉”。比如从1开始连续加1e-8加上几百万次之后结果可能不再变化。这种时候最好分段求和或改定点。延迟账浮点加法器/乘法器IP核通常有5~20拍的延迟流水线打得很深。如果你的系统对反馈环路延迟敏感浮点会让环路增益的相位裕度变差。资源账一个单精度浮点乘法器复杂度大约是一个18x18定点乘法器的数倍。在成本敏感的芯片上能定点就定点浮点只留给必须用高动态范围的地方。5. 实操用Verilog实现一个定点小数运算单元以Q8.8为例5.1 需求计算两个Q8.8定点小数的乘积假设我们要做一个音频增益模块输入是16位有符号音频数据按Q8.8格式解释增益也是Q8.8格式输出仍然需要Q8.8格式。要求输出做饱和处理防止溢出造成爆音。这里的Q8.81位符号7位整数8位小数。总位宽16位。输入x和增益g都在[-128, 127.99609375]之间乘积的实际范围是[-16384, 16383.999...]如果直接输出16位一定会溢出所以必须饱和。5.2 代码实现参数化乘加模块下面给出一个可直接用的Verilog模块module q88_mul_sat ( input wire clk, input wire rst_n, input wire signed [15:0] x, // Q8.8 input wire signed [15:0] gain, // Q8.8 output reg signed [15:0] y // Q8.8 ); // 第一步乘法结果位宽为32位 wire signed [31:0] mul_result; assign mul_result x * gain; // 注意两个Q8.8相乘后是Q16.16需要转回Q8.8 // 转换方式右移8位然后饱和到16位 wire signed [23:0] shifted; assign shifted mul_result 8; // 算术右移保留符号 // 加上舍入位第7位为0.5右移8位前加上0.5*scale0x80 wire signed [23:0] shifted_round; assign shifted_round shifted (mul_result[7] ? 24sh1 : 24sh0); reg signed [15:0] y_reg; always (posedge clk or negedge rst_n) begin if (!rst_n) begin y_reg 16sh0; end else begin // 饱和判断 if (shifted_round 16sd32767) y_reg 16sd32767; else if (shifted_round -16sd32768) y_reg -16sd32768; else y_reg shifted_round[15:0]; end end assign y y_reg; endmodule这里有一个容易忽略的细节mul_result是32位shifted我取了24位然后再截到16位。为什么不直接对32位做饱和因为右移8位之后mul_result的高16位和次高8位才影响最终结果低8位只用于舍入。所以先算术右移再饱和是更合理的避免判断范围时出错。5.3 仿真测试与位宽检查testbench里我用随机数验证并和real类型计算的期望值对比timescale 1ns / 1ps module tb_q88_mul_sat; reg clk 0; reg rst_n 0; reg signed [15:0] x; reg signed [15:0] gain; wire signed [15:0] y; q88_mul_sat dut ( .clk(clk), .rst_n(rst_n), .x(x), .gain(gain), .y(y) ); always #5 clk ~clk; real x_real, g_real, y_real, y_q88_real; integer errors 0; initial begin rst_n 0; #20 rst_n 1; for (int i 0; i 10000; i) begin x $random; gain $random; (posedge clk); #1; x_real x / 256.0; // 从Q8.8转真实值 g_real gain / 256.0; y_real x_real * g_real; // 期望输出转换成Q8.8并做饱和 if (y_real 32767.0/256.0) y_q88_real 32767.0/256.0; else if (y_real -32768.0/256.0) y_q88_real -32768.0/256.0; else y_q88_real y_real; // 比较允许误差半个LSB if (abs(y/256.0 - y_q88_real) 0.002) errors; end if (errors 0) $display(TEST PASSED); else $display(TEST FAILED: %0d errors, errors); $finish; end function real abs(input real x); abs (x 0) ? x : -x; endfunction endmodule实际跑下来误差基本都在舍入位以内说明模块的位宽和饱和逻辑是对的。5.4 在FPGA上跑起来资源与时序把上面的模块综合到Xilinx Artix-7上只用了1个DSP48E1和几十个LUT时序轻松跑到200MHz以上。如果不用DSP纯LUT也能实现但资源会多很多。工程上我会在综合属性里指定乘法器使用DSP比如(* use_dsp yes *) wire signed [31:0] mul_result; assign mul_result x * gain;如果你的芯片DSP资源紧张也可以改用移位加实现有符号乘法不过那通常只针对常数乘法。6. 常见问题与排查技巧实录6.1 定点数乘法结果位宽为什么总是要翻倍这是新手问得最多的问题。两个N位定点数相乘结果位宽是2N这是数学上保证不溢出的最小位宽。举例Q4.4表示范围约[-8, 7.9375]两个最值相乘是(-8)*(-8)64而Q4.4最大才7.9375所以直接存在16位里是必然溢出的如果用Q8.8存结果数值范围约[-128,127.996]就能装下。也就是说位宽翻倍不是约定而是保证乘法结果完整的前提。6.2 为什么减法/比较器里无符号数容易翻车因为无符号减法会用补码“绕回”。例如4b0001 - 4b0010 4b1111你明明想得到-1但无符号解释成了15。比较器也一样4b1000如果按无符号解释是8按有符号解释是-8同一个bit pattern结果完全不同。解决方式就一句话能从算法层确认会有负数的信号一律声明为signed并保证参与运算的所有信号都是signed。如果某个接口协议必须用无符号请在边界处显式转换不要在整个运算链路里混用。6.3 浮点IP核和定点的取舍什么时候必须用浮点我个人的经验法则是如果信号动态范围超过3~4个数量级或者多级运算中间值可能比输入大好几个量级优先考虑浮点。如果系统是纯实时流、延迟敏感比如电机FOC、无线通信的AGC基本都用定点。如果做科学计算、离线数据处理只是为了验证算法、做性能评估可以在PC上用double跑最后把算法定点化映射到硬件。很多项目最后发现真正必须用浮点的场景不足10%其余90%都可以经过精心定标后转成定点换来更小的面积、更低的功耗和更确定的时序。6.4 仿真里C语言/Matlab对照的坑做硬件算法验证时我习惯用C语言或Matlab做浮点模型再写定点模型最后把定点结果转成RTL参考。这里最大的坑是“量化方式不一致”。比如我在Matlab里用floor截位但RTL里用的是算术右移相当于向负无穷舍入这两种方式在正数上一样在负数上完全不同。又比如C语言的整数除法是向0截断而Verilog的有符号除法有的工具是按向负无穷还是向0实现不同综合工具可能不一致。所以一定要先明确量化策略并且让所有语言的参考模型用同一套舍入规则。6.5 综合工具报告“cannot infer multiplier”怎么解决这个报错通常不是因为乘法和位宽本身有问题而是因为操作数位数太大超出了DSP硬件的位宽。比如两个64位有符号数相乘FPGA里的DSP48E1只能做25x18或27x18乘法综合工具会报告无法推断。解决办法是把大乘法拆成小乘法组合比如高32位和低32位分别相乘再组合。使用厂商提供的乘法器IP核配置成多DSP级联模式。或者从算法层面降低位宽毕竟64位定点运算在绝大多数信号处理里是过剩的。7. 个人实际使用中的几个小习惯最后分享几个我在这类项目里养成的习惯不一定写在教科书上但真的能少加几天班。第一在模块端口注释里标清定点格式。比如// axi_data: Q4.12 signed, range [-8, 7.999]不要相信自己的记忆也不要相信同事的记忆这类注释在团队协作里价值极大。第二把所有定点参数集中到一个头文件或package里定义。比如package dsp_pkg; localparam int unsigned INPUT_FRAC_BITS 12; localparam int unsigned INPUT_INT_BITS 4; localparam int unsigned DATA_WIDTH 1 INPUT_INT_BITS INPUT_FRAC_BITS; endpackage后续改格式时只要动一处所有模块自动跟着变。第三仿真里一定要测边界值包括0x8000、0x7FFF、0xFFFF这些极端值不要只做随机数测试。很多溢出只有边界值才会触发。第四在关键运算前后加断言assert比如检查结果是否溢出。用SystemVerilog的话可以写成always_ff (posedge clk) begin if (valid) assert(y -16sd32768 y 16sd32767) else $error(Overflow at time %0t, y%0d, $time, y); end第五不要用/和%处理可综合的通用除法除非数据率真的很低。除法器要么用IP核要么用CORDIC要么用移位近似。你写一个/也许仿真没问题但综合出来的面积和时序会让你怀疑人生。数值表示这件事说难不难说简单也不简单。我见过不少人卡在“为什么我的滤波器输出是乱码”这种问题上最后定位到就是定点格式没对齐。希望这篇文章里提到的方法和坑能帮你把verilog中的定点数、浮点数、定点小数、定点整数的表示及运算一次理清楚。以后写RTL时心里始终装着“这根总线到底代表什么数值、位宽够不够、舍入怎么处理”这三问很多问题就会自然消失了。
返回列表