一、为什么选择定点数?先想清楚再动手
在硬件里,浮点数运算器(FPU)是个大家伙。它要处理符号位、指数位、尾数位,还要做对齐、规格化、舍入,每一步都需要大量的逻辑门和时钟周期。如果你的系统对精度要求不是那么苛刻,或者数据范围相对固定,那么定点数就是你的“性能加速器”。
定点数的核心思想很简单:我们约定好一个小数点的固定位置。比如,我用一个16位的寄存器,我可以在心里约定,它的最低4位是小数部分,那么它就能表示 整数部分 * 2^4 + 小数部分 / 2^4 这样的数值。所有运算都基于这个约定,硬件上只需要做整数运算,速度快,面积小。
应用场景举例:
- 音频处理:音频PCM数据通常在-1.0到1.0之间,非常适合用定点数表示。
- 传感器数据融合:来自陀螺仪、加速度计的数据,范围有限,精度要求适中。
- 通信中的信道均衡:系数和信号处理常用定点算法。
- 嵌入式控制算法:PID控制器中的比例、积分、微分项计算。
关键优点:
- 速度快:直接使用整数ALU,单周期完成加减,乘法也很快。
- 面积小:无需复杂的指数处理、规格化单元。
- 功耗低:逻辑简单,翻转活动少。
- 确定性:运算耗时固定,没有浮点运算的异常(如NaN, Inf)。
主要缺点:
- 动态范围有限:需要预先估算数据的最大最小值,否则容易溢出或损失精度。
- 精度固定:小数位固定,对于变化范围大的数据,需要精心设计格式。
- 设计负担:需要开发者手动管理精度和溢出,增加了设计复杂度。
二、核心技巧:Q格式与精度管理
我们通常用Q格式来定义定点数。Qm.n 表示有m位整数位(包含一位符号位),n位小数位,总位宽为 m+n。例如,Q1.15 表示1位符号位,0位整数位(因为符号位占了一位整数位),15位小数位,这是表示-1到1之间数值的常用格式。
技巧一:统一内部数据格式 在整个数据处理流水线中,尽量保持中间变量的Q格式一致。频繁转换格式会引入额外的移位操作,增加延迟和资源消耗。
技巧二:乘法结果的位宽处理
两个定点数相乘,结果的位宽会扩大。若A是Qa.b, B是Qc.d, 乘积P的格式是 Q(a+c).(b+d)。通常我们需要将结果截断或舍入回原有的位宽。这里就需要做取舍。
技巧三:累加时的位宽扩展 在做累加或积分运算时(比如FIR滤波器、PID中的积分项),和值可能会不断增长,必须使用更宽的寄存器(例如,扩展8-16位)来防止溢出,最后再根据需要截断输出。
下面,我们结合一个完整的示例来感受一下。假设我们要实现一个简单的单极点低通滤波器,公式为:y[n] = alpha * x[n] + (1 - alpha) * y[n-1], 其中alpha是一个介于0和1之间的系数。
技术栈:Verilog-2001
// 示例:基于定点数的单极点低通滤波器实现
module lowpass_filter_fixed #(
parameter DATA_WIDTH = 16, // 输入输出数据位宽
parameter COEF_WIDTH = 16, // 系数位宽
parameter Q_FORMAT = 14 // 小数部分位数, 格式为 Q2.14 (因为16-14=2位整数,包含符号)
) (
input wire clk,
input wire rst_n,
input wire signed [DATA_WIDTH-1:0] data_in, // 有符号输入, Q2.14格式
output reg signed [DATA_WIDTH-1:0] data_out // 有符号输出, Q2.14格式
);
// 系数alpha, 例如0.125, 在Q2.14格式下为 0.125 * 2^14 = 2048
localparam signed [COEF_WIDTH-1:0] ALPHA = 16'sd2048; // Q2.14格式的0.125
// (1 - alpha) = 0.875, Q2.14格式下为 0.875 * 2^14 = 14336
localparam signed [COEF_WIDTH-1:0] ONE_MINUS_ALPHA = 16'sd14336;
// 内部信号声明
reg signed [DATA_WIDTH-1:0] y_prev; // 上一次的输出值 y[n-1]
wire signed [DATA_WIDTH+COEF_WIDTH-1:0] prod_alpha; // alpha * x[n] 的乘积,位宽扩展
wire signed [DATA_WIDTH+COEF_WIDTH-1:0] prod_one_minus; // (1-alpha) * y_prev 的乘积
wire signed [DATA_WIDTH+COEF_WIDTH:0] sum_full; // 全精度和, 多一位防止进位溢出
wire signed [DATA_WIDTH-1:0] sum_truncated; // 截断/舍入后的和
// 计算 alpha * x[n]
assign prod_alpha = ALPHA * data_in; // 结果格式为 Q4.28
// 计算 (1-alpha) * y[n-1]
assign prod_one_minus = ONE_MINUS_ALPHA * y_prev; // 结果格式为 Q4.28
// 将两个乘积相加。注意它们都是Q4.28格式,对齐相加。
// 使用更宽一位的变量来容纳可能的进位
assign sum_full = prod_alpha + prod_one_minus; // 结果格式可视为 Q5.28
// **关键步骤:结果截断与舍入**
// 我们需要从Q5.28转换回Q2.14。
// 1. 保留整数部分和所需的小数位:sum_full[31:14] 是 Q5.14 格式的数据(31-14+1=18位)。
// 2. 但我们最终需要16位Q2.14,所以需要截断高位的整数部分。
// 3. 这里我们假设运算不会导致真正的整数部分溢出(Q5的整数位足够),
// 所以我们简单取[29:14]这16位(即丢弃最高两位和最低14位小数)。
// 4. 为了减少截断误差,我们采用“四舍五入”:加上舍入因子(低位小数部分的一半,即2^(14-1))后再截断。
localparam ROUND_BIT = (1 << (Q_FORMAT - 1)); // 舍入因子, 2^13
assign sum_truncated = (sum_full + ROUND_BIT) >>> Q_FORMAT; // 算术右移14位并舍入
// 寄存器更新逻辑
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
y_prev <= 0;
data_out <= 0;
end else begin
y_prev <= sum_truncated; // 更新历史值
data_out <= sum_truncated; // 输出当前滤波值
end
end
endmodule
三、进阶优化:移位代替乘除法
在FPGA或ASIC中,乘法器仍然是比较耗资源的。如果一个系数是2的幂次方的倒数,比如0.125(1/8),0.0625(1/16),那么乘法可以用右移操作完美替代。同样,乘以2的幂次方可以用左移。
优化示例:如果我们的alpha是0.0625,那么 alpha * x 就等于 x >> 4。(1 - alpha) * y 可以转化为 y - (y >> 4)。这样就把两个乘法优化掉了,速度极快,面积极小。
// 示例:使用移位优化后的低通滤波器
module lowpass_filter_shift #(
parameter DATA_WIDTH = 16,
parameter SHIFT_BITS = 4 // alpha = 2^-SHIFT_BITS, 即 1/16 = 0.0625
) (
input wire clk,
input wire rst_n,
input wire signed [DATA_WIDTH-1:0] data_in,
output reg signed [DATA_WIDTH-1:0] data_out
);
reg signed [DATA_WIDTH-1:0] y_prev;
// 内部使用更宽的位宽进行中间计算,防止移位和减法时的精度损失和溢出
wire signed [DATA_WIDTH+SHIFT_BITS:0] y_prev_ext; // 扩展位宽
wire signed [DATA_WIDTH+SHIFT_BITS:0] y_shifted;
wire signed [DATA_WIDTH+SHIFT_BITS:0] y_feedback_part;
wire signed [DATA_WIDTH+SHIFT_BITS:0] sum_ext;
wire signed [DATA_WIDTH-1:0] sum_final;
// 扩展历史值,为移位和减法提供保护位
assign y_prev_ext = { {SHIFT_BITS+1{y_prev[DATA_WIDTH-1]}}, y_prev }; // 符号位扩展
// 计算 alpha * x[n] : 直接对输入右移
// 计算 (1-alpha)*y[n-1] = y[n-1] - alpha*y[n-1]
assign y_shifted = y_prev_ext >>> SHIFT_BITS; // alpha * y[n-1], 算术右移
assign y_feedback_part = y_prev_ext - y_shifted; // (1-alpha)*y[n-1]
// 同样扩展输入,与反馈部分相加
assign sum_ext = ( { {SHIFT_BITS+1{data_in[DATA_WIDTH-1]}}, data_in} >>> SHIFT_BITS )
+ y_feedback_part;
// 结果饱和处理,而不是简单截断。这是防止溢出的重要安全措施。
// 将扩展后的结果饱和到原始数据位宽所能表示的范围。
saturate #(
.IN_WIDTH (DATA_WIDTH+SHIFT_BITS+1),
.OUT_WIDTH(DATA_WIDTH)
) u_saturate (
.data_in (sum_ext),
.data_out(sum_final)
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
y_prev <= 0;
data_out <= 0;
end else begin
y_prev <= sum_final;
data_out <= sum_final;
end
end
endmodule
// 一个简单的有符号数饱和处理模块
module saturate #(
parameter IN_WIDTH = 24,
parameter OUT_WIDTH = 16
)(
input wire signed [IN_WIDTH-1:0] data_in,
output reg signed [OUT_WIDTH-1:0] data_out
);
// 定义输出数据的最大值和最小值
localparam signed MAX_POS = (1 << (OUT_WIDTH-1)) - 1;
localparam signed MAX_NEG = -(1 << (OUT_WIDTH-1));
always @(*) begin
if (data_in > MAX_POS) begin
data_out = MAX_POS; // 正饱和
end else if (data_in < MAX_NEG) begin
data_out = MAX_NEG; // 负饱和
end else begin
data_out = data_in[OUT_WIDTH-1:0]; // 在范围内,直接截断低位
end
end
endmodule
这个优化版本完全用加法和移位替代了乘法,并引入了饱和处理这个关键概念,这比简单的截断能更安全地处理溢出,避免在信号达到峰值时出现严重的失真。
四、重要的注意事项与总结
注意事项:
- 仿真与验证:务必使用大量的测试向量进行仿真,包括正常值、边界值(最大/最小)和可能引起溢出的极端值。对比软件浮点模型(用Python/Matlab写一个)的结果,计算定点化带来的信噪比(SNR)或误差。
- 溢出是头号敌人:始终对数据范围保持警惕。在累加、积分或增益较大的环节,优先考虑使用保护位(扩展位宽)和饱和运算,而不是简单的截断。
- 精度与资源的权衡:增加小数位数(Q格式中的n)能提高精度,但也会增加位宽,消耗更多寄存器和布线资源。需要找到满足系统性能指标下的最优点。
- 时钟与时序:复杂的定点运算(尤其是长位宽的乘法)可能形成关键路径。必要时需要通过流水线打拍来提升系统最高工作频率。
文章总结: Verilog浮点运算的定点化实现,是一门在资源、速度和精度之间寻求平衡的艺术。它要求开发者从算法层面深入理解数据流,并具备硬件思维。核心步骤可以概括为:分析数据范围 -> 确定Q格式 -> 规划运算位宽扩展 -> 设计舍入/饱和策略 -> 用移位优化特定运算 -> 严格仿真验证。
掌握这些技巧后,你就能为你的数字电路设计出既高效又可靠的“定制版浮点运算单元”,在激烈的芯片性能竞争中占据优势。记住,最好的设计永远是恰好满足需求的设计,定点数优化正是这一理念的完美体现。
评论