Verilog有符号与无符号数转换:从补码原理到FPGA实战避坑指南

📅 2026/7/31 8:41:54 👁️ 阅读次数 📝 编程学习
Verilog有符号与无符号数转换:从补码原理到FPGA实战避坑指南

1. 项目概述:从一次仿真Bug说起

最近在调一个图像处理相关的FPGA模块,里面涉及到一些滤波系数的运算。仿真的时候,数据流看起来一切正常,但输出图像的边缘部分总会出现一些奇怪的、非预期的亮斑或暗斑。排查了很久,从算法到时序都查了一遍,最后定位到问题出在一行看起来很简单的赋值语句上:result = coeff * pixel_data;。问题在于,coeff被我定义成了signed(有符号数),而pixel_data是摄像头输入的unsigned(无符号数)。在Verilog中,当有符号数和无符号数进行混合运算时,如果你没有显式地告诉工具你的意图,它可能会按照一套默认的、有时反直觉的规则进行处理,导致计算结果与你的预期大相径庭。这个“有符号数与无符号数的相互转化”问题,看似基础,却是我踩过最深、也最隐蔽的坑之一。它不仅仅是语法问题,更关乎你对数字电路底层数据表示的理解。无论是做信号处理、通信算法还是控制逻辑,只要涉及数学运算,这都是一个绕不开的核心知识点。今天,我就结合自己踩坑和填坑的经验,把Verilog里有符号数和无符号数转换的那些门道彻底讲清楚,让你在写代码时心里有底,调试时有的放矢。

2. 核心概念:补码、位宽与工具视角

在深入转换之前,我们必须统一认知:在Verilog(以及绝大多数数字系统)中,数据本身并没有“符号”属性,它只是一串二进制位(bits)。所谓的“有符号数”或“无符号数”,是我们(程序员)和综合/仿真工具附加在这串二进制位上的“解读方式”或“处理规则”

2.1 二进制世界的两种解读

假设我们有一个4位的寄存器reg [3:0] a = 4‘b1111;

  • 无符号数解读:我们把它当作一个纯二进制数。4‘b1111就是 15(18 + 14 + 12 + 11)。
  • 有符号数解读:我们采用二进制补码规则。最高位(第3位)是符号位(1表示负),剩余位是数值位(按补码规则解读)。4‘b1111在补码中表示 -1(计算方式:先取反得0000,再加1得0001,所以是 -1)。

关键在于,这串1111的物理存在没有变,变的是我们和工具看待它的“眼镜”。

2.2 补码:硬件友好的表示法

为什么用补码?因为它让加法和减法在硬件上统一了。对于减法A - B,硬件可以将其计算为A + (~B + 1)(即A加上B的补码)。这样,CPU或FPGA内部只需要加法器就能完成加减运算,极大地简化了设计。理解补码是理解有符号数运算的基础。

2.3 变量的“符号属性”声明

在Verilog中,我们通过以下方式声明变量的符号属性:

  • reg [7:0] data_u;// 默认或无关键字,通常被工具视为无符号数
  • reg signed [7:0] data_s;// 使用signed关键字,明确声明为有符号数

这个声明至关重要,因为它直接影响:

  1. 仿真行为:仿真器如何显示这个变量的值(是显示十进制正数还是可能显示负数)。
  2. 运算规则:当这个变量参与+,-,*,>,<等运算时,工具采用哪套规则。
  3. 综合结果:综合工具可能会根据运算类型生成不同的电路结构(例如,选择有符号数乘法器IP核还是无符号数乘法器IP核)。

2.4 工具如何对待未声明的数?

这里有一个巨大的陷阱:直接写在代码中的数字常量,例如8-5, 它的符号和位宽是由上下文决定的!Verilog有一个复杂的“整数常量”规则。简单来说,一个没有指定位宽和进制的十进制数(如-5),会被当作有符号整数来处理。但更常见也更安全的方法是使用明确的格式:

  • 8‘d10: 8位宽,十进制,数值10。通常被视为无符号
  • 8‘sd10: 8位宽,十进制,有符号,数值10。
  • -8‘sd5: 8位宽,十进制,有符号,数值-5。
  • 8‘b1111_1010: 就是一串二进制位,其符号属性由赋值目标或运算上下文决定。

我的踩坑心得:养成对常量也指定符号和位宽的习惯,尤其是在进行混合类型运算时。写data_s + 8‘sd1比写data_s + 1要清晰和安全得多,能避免很多意想不到的符号扩展问题。

3. 转换的本质与核心方法

转换的核心目标:在保持二进制位序列的物理意义(即电路连接)基本不变的前提下,改变工具和我们对这串二进制位的“解读规则”。

3.1 无符号数转有符号数

场景:你从ADC(模数转换器)读到一个无符号数据adc_data[11:0],范围0~4095。现在你需要对其进行数字滤波,滤波系数是有负数的,因此需要将ADC数据当作有符号数来处理(比如,将零点设在2048,范围变为-2048~+2047)。

方法1:直接赋值(最常用,但需理解位宽匹配)

reg [11:0] adc_data_u; // 无符号,0~4095 reg signed [12:0] adc_data_s; // 有符号,我们想得到 -2048~+2047 // 方法:先进行位宽扩展,再赋值 adc_data_s = {1‘b0, adc_data_u}; // 错误!这只是简单的拼接,adc_data_s[12]是0,正数范围是0~4095,不是我们想要的-2048~+2047。 adc_data_s = $signed({1‘b0, adc_data_u}); // 仍然不对。$signed()只是改变“解读方式”,但{1‘b0, adc_data_u}本身是一个13位的无符号数,最高位是0,$signed()解读后还是一个正数。

正确的做法是进行“符号化偏移”

// 核心思想:将无符号的原始值,减去一个偏移量(如2048),将其转化为以该偏移量为零点的有符号数。 localparam OFFSET = 12‘d2048; // 偏移量 reg signed [12:0] adc_data_s; // 需要多1位,因为减法可能产生负数 always @(posedge clk) begin adc_data_s <= $signed(adc_data_u) - $signed(OFFSET); end // 此时,当 adc_data_u = 0 时,adc_data_s = -2048;当 adc_data_u = 4095时,adc_data_s = 2047。

方法2:使用 $signed() 系统函数$signed()函数的作用是将一个表达式的结果当作有符号数来解释。它不改变底层的位值,只改变“类型标签”。

reg [7:0] unsigned_data = 8‘hFF; // 二进制 11111111,无符号解读为 255 reg signed [7:0] signed_data; signed_data = $signed(unsigned_data); // 将 unsigned_data 的值(255,即8‘hFF)当作有符号数解读。 // 仿真器会显示 signed_data 的值为 -1。

关键点$signed(unsigned_data)并不意味着把255“转换”成了-1的位模式。它意味着工具在接下来的运算中,会把unsigned_data当前的值(8‘hFF)当成补码-1来处理。如果unsigned_data的值是 8‘h7F(127),那么$signed()后就被当成+127。

3.2 有符号数转无符号数

场景:你完成了一个有符号滤波运算,得到了一个负的结果filter_out_s,现在需要将其送到DAC(数模转换器)输出,而DAC通常需要无符号的正整数输入。

方法1:直接赋值(同样注意位宽与偏移)

reg signed [15:0] filter_out_s; // 滤波结果,有正有负 reg [15:0] dac_data_u; // 输出给DAC,需要是0~65535的正数 // 如果DAC的零点是中间值32768 localparam DAC_OFFSET = 16‘d32768; always @(posedge clk) begin dac_data_u <= filter_out_s + DAC_OFFSET; // 先加上偏移量,使其变为正数 // 注意:filter_out_s + DAC_OFFSET 这个表达式的结果,由于包含有符号数,默认会被当作有符号数运算。 // 但赋值给无符号的 dac_data_u 时,会发生隐式转换。为了清晰,可以: // dac_data_u <= $unsigned(filter_out_s + DAC_OFFSET); end

方法2:使用 $unsigned() 系统函数$unsigned()$signed()相反,它强制将表达式结果当作无符号数解释。

reg signed [7:0] signed_data = -1; // 补码表示为 8‘hFF reg [7:0] unsigned_data; unsigned_data = $unsigned(signed_data); // 将 signed_data 的值(-1)当作无符号数解读。 // 仿真器会显示 unsigned_data 的值为 255。

重要警告:直接将一个负的有符号数赋值给无符号变量,或者用$unsigned()包裹一个负值,不会产生任何数值上的转换,它只是粗暴地改变了“解读方式”。-1(8‘hFF)被解读为无符号数就是255。这通常不是你想要的结果!你真正需要的往往是一个“数值上的平移”,如上文的加偏移量示例。

3.3 转换中的位宽扩展问题

这是转换时最容易出错的地方。当目标位宽大于源位宽时,需要进行位扩展。

  • 无符号数扩展:高位补0。这叫零扩展
    reg [3:0] small_u = 4‘b1010; // 10 reg [7:0] large_u; large_u = {4‘b0000, small_u}; // 8‘b00001010, 仍然是10
  • 有符号数扩展:高位补符号位(最高位的值)。这叫符号扩展。这是为了保持数值不变(例如,4位有符号数-6(1010)扩展为8位,应该是-6(11111010),而不是10(00001010))。
    reg signed [3:0] small_s = 4‘b1010; // -6 (补码) reg signed [7:0] large_s; large_s = {{4{small_s[3]}}, small_s}; // 8‘b11111010, 仍然是 -6 // {{4{small_s[3]}}} 是Verilog的复制运算符,将 small_s 的最高位复制4次。

隐式扩展的陷阱

reg signed [7:0] a = 8‘sh8F; // -113 reg [15:0] b; b = a; // 这里会发生什么?

赋值时,工具需要将8位的a扩展到16位来匹配b。因为b是无符号的,你可能会期待零扩展。但Verilog的规则是:在赋值过程中,如果源表达式是有符号的,会先进行符号扩展,然后再进行赋值。所以a(-113) 先被符号扩展为16位的16‘shFF8F(仍然是-113),然后这个位模式被赋值给无符号的bb的值将是 65423(即 16‘hFF8F)!这很可能不是你想要的结果。

正确的做法是显式控制扩展

b = {8‘h00, a}; // 零扩展,b = 143? 不对!a是8‘sh8F (143的位模式,但作为有符号数是-113)。{8‘h00, 8‘sh8F} 得到 16‘h008F,即 143。 // 看,这又回到了“解读”问题。如果你想要 a 的“数值”143,你应该用: b = $unsigned(a); // 但 $unsigned(a) 得到的是 8‘h8F = 143,然后零扩展为16位,b=143。 // 如果你想要 a 的“位模式”进行零扩展,那就是上面 {8‘h00, a} 的做法。

可见,必须非常清楚你转换的意图:是保持“数值”不变,还是进行某种“位模式”的映射?

4. 混合类型运算的黄金法则

当表达式中同时出现有符号和无符号操作数时,Verilog会根据一套优先级规则,将所有操作数转换为同一类型后再计算。这套规则比较复杂,但记住以下黄金法则可以避免99%的问题:

法则一:显式声明优于隐式转换对于任何可能产生歧义的运算,使用$signed()$unsigned()显式地标记每一个操作数的类型。这相当于告诉工具:“请按我指定的方式来解读这个数据”。

法则二:警惕比较运算符>,<,>=,<=这些关系运算符,在混合类型比较时极易出错。

reg [7:0] u_data = 8‘hFF; // 255 reg signed [7:0] s_data = 8‘hFF; // -1 if (u_data > s_data) // 危险!混合比较 $display(“u_data > s_data”); else $display(“u_data <= s_data”);

结果可能会输出u_data <= s_data!因为工具可能会把u_data也当作有符号数来比较(255的补码形式是-1?不,这里涉及整型提升,规则很绕)。绝对安全的做法是:

if ($unsigned(u_data) > $unsigned(s_data)) // 都转为无符号比, 255 > 255? 不,s_data转无符号是255,所以相等。 if ($signed(u_data) > $signed(s_data)) // 都转为有符号比, -1 > -1? 相等。 // 更好的做法:在比较前,先将数据转换到你期望的数值范围再比。 if (u_data > (s_data + 128)) // 假设你想把s_data的零点偏移

法则三:赋值时的“类型覆盖”赋值语句LHS = RHS;最终结果的类型是由左值LHS的类型决定的。但RHS表达式计算过程中的类型转换会影响中间结果。确保RHS的计算过程符合你的预期。

法则四:使用中间变量明确转换步骤对于复杂的表达式,不要追求一行写完。拆分成多步,用中间变量存储转换结果,这样代码更清晰,也更容易调试。

// 模糊的写法 output_data = (coeff_s * adc_data_u) >> 8; // 清晰的写法 reg signed [19:0] mult_result_s; // 假设位宽足够 reg signed [11:0] adc_data_s_tmp; always @(posedge clk) begin // 步骤1:将无符号ADC数据转换到有符号域(减去偏移) adc_data_s_tmp <= $signed(adc_data_u) - OFFSET; // 步骤2:进行有符号乘法 mult_result_s <= coeff_s * adc_data_s_tmp; // 步骤3:截位或缩放,赋值给输出(输出可能又是无符号的) output_data <= $unsigned((mult_result_s >>> 8) + OUTPUT_OFFSET); // >>> 是算术右移,会保持符号 end

5. 实战案例:一个简单的有符号FIR滤波器

让我们设计一个3抽头的有符号FIR滤波器,输入是无符号的ADC数据,系数是有符号的,输出最终要转换为无符号送给DAC。

module signed_fir_filter ( input wire clk, input wire rst_n, input wire [11:0] adc_data_u, // 无符号输入,0-4095 output reg [11:0] dac_data_u // 无符号输出,0-4095 ); // 参数定义 localparam signed [7:0] COEFF_0 = 8‘sd30; // 明确指定有符号系数 localparam signed [7:0] COEFF_1 = 8‘sd100; localparam signed [7:0] COEFF_2 = 8‘sd30; localparam OFFSET = 12‘d2048; // ADC零点偏移,使输入范围变为 -2048 ~ +2047 localparam OUTPUT_GAIN = 4; // 输出增益(放大倍数) localparam OUTPUT_OFFSET = 12‘d2048; // 输出偏移,使范围回到 0~4095 // 输入数据寄存与转换 reg signed [12:0] adc_data_s; // 13位有符号,容纳 -2048~+2047 reg signed [12:0] data_delay1, data_delay2; // 延迟线 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin adc_data_s <= 13‘sd0; data_delay1 <= 13‘sd0; data_delay2 <= 13‘sd0; end else begin // 关键转换步骤1:无符号输入 -> 有符号处理域 adc_data_s <= $signed(adc_data_u) - $signed(OFFSET); data_delay1 <= adc_data_s; data_delay2 <= data_delay1; end end // 有符号乘累加 reg signed [21:0] mult_acc_s; // 乘法累加结果,需要足够位宽防止溢出 // 位宽计算:输入13位,系数8位,乘法结果21位。3个乘积相加,最多23位,我们取24位。 reg signed [23:0] sum_s; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin mult_acc_s <= 22‘sd0; sum_s <= 24‘sd0; end else begin // 有符号乘法,使用 signed 声明的变量和常量,确保是有符号乘法 mult_acc_s <= (adc_data_s * COEFF_0) + (data_delay1 * COEFF_1) + (data_delay2 * COEFF_2); // 应用增益并截位(这里简单右移,相当于除以4) sum_s <= mult_acc_s * OUTPUT_GAIN; // 先增益 end end // 输出转换:有符号域 -> 无符号输出 reg signed [23:0] sum_shifted; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin sum_shifted <= 24‘sd0; dac_data_u <= 12‘d0; end else begin // 算术右移,保持符号,进行缩放 sum_shifted <= sum_s >>> 2; // 除以4 // 关键转换步骤2:有符号结果 -> 无符号输出域 // 1. 加上输出偏移量,将零点移回 // 2. 使用 $unsigned 改变解读方式 // 3. 同时进行饱和处理,防止超出0-4095范围 if (($signed(sum_shifted) + OUTPUT_OFFSET) > 4095) dac_data_u <= 12‘d4095; else if (($signed(sum_shifted) + OUTPUT_OFFSET) < 0) dac_data_u <= 12‘d0; else dac_data_u <= $unsigned($signed(sum_shifted) + OUTPUT_OFFSET); // 先做有符号加法,再转无符号 end end endmodule

在这个例子中,转换清晰地发生在两个边界:

  1. 输入边界adc_data_u - OFFSET,并用$signed()包裹,确保后续运算在有符号域进行。
  2. 输出边界sum_shifted + OUTPUT_OFFSET,用$unsigned()包裹,将最终数值转换回DAC需要的无符号格式。饱和处理是防止转换后溢出的重要步骤。

6. 仿真、调试与常见问题排查

6.1 仿真器显示差异

在Modelsim、VCS等仿真器中,一个变量显示的值取决于它的声明。

  • reg [7:0] a = 8‘hFF;仿真器可能显示255
  • reg signed [7:0] b = 8‘hFF;仿真器可能显示-1调试技巧:在查看波形时,可以同时添加该信号的“无符号格式”和“有符号格式”。例如,在Modelsim中,你可以对同一个信号data,添加data(默认显示),再添加一个虚拟信号$signed(data)$unsigned(data)来观察不同解读下的值。这能帮你快速定位是否是符号解读错误导致的问题。

6.2 综合警告与错误

综合工具(如Vivado、Quartus)可能会对混合类型运算提出警告。

  • 警告示例[Synth 8-5536] Signed/unsigned mismatch in expression.
    • 不要忽略这个警告!它提示你的代码中存在潜在的逻辑错误风险。仔细检查警告所在行的表达式,按照黄金法则显式转换操作数类型。
  • 位宽截断警告:当你将一个较宽的有符号数赋值给较窄的无符号变量时,综合工具会警告可能的数据丢失。你需要确认这种截断是否是设计意图(例如,丢弃高位是安全的),或者你是否需要先进行饱和处理。

6.3 常见问题速查表

现象可能原因排查与解决方法
仿真结果与数学计算不符混合类型运算未显式转换检查所有运算符两边的操作数类型,使用$signed()/$unsigned()显式标记。
输出数据出现非预期的跳变(如从正数突变为很大的数)有符号负数被误当作无符号数使用检查从有符号到无符号的转换点,是否忘记了加偏移量进行数值映射,而只是简单改变了类型解读。
乘法或加法结果符号错误常量未指定符号和位宽检查代码中的数字常量,特别是负数。将-1改为8‘sd-1-8‘sd1
比较逻辑始终为真或为假关系运算符两边的类型不一致在比较前,将两边操作数统一转换为同一种类型(通常转换为有符号数进行比较更符合直觉)。
资源使用(如DSP)比预期多工具无法推断出有符号乘法,可能生成了更通用的乘法器确保乘法操作的两个操作数都被明确声明为signed,或者使用$signed()包裹。这样工具才能调用优化的有符号乘法器IP。
时序仿真与功能仿真结果不同在赋值时,有符号数的符号扩展行为与预期不符仔细检查位宽扩展的代码。对于有符号数赋值给更宽的无符号数,考虑是否先进行了零扩展{0, signed_data}而不是依赖默认的符号扩展。

6.4 我的调试心得

  1. 波形图是你的朋友:遇到运算问题,第一件事是把所有相关信号(原始输入、转换后的中间值、运算结果、最终输出)都拉到波形图里。同时观察它们的十六进制形式有/无符号十进制形式。十六进制形式反映了真实的位模式,十进制形式反映了当前的解读方式,两者对比往往能立刻发现问题。
  2. 编写小型测试平台:针对复杂的转换逻辑,不要在主测试平台里调试。单独写一个简单的tb_conversion.v,用几个边界值(如最大值、最小值、0、-1)进行测试,验证转换函数是否正确。
  3. 理解工具的“默认行为”:花点时间阅读你所用的FPGA开发工具(Vivado/Quartus)和仿真器(Modelsim/VCS)关于有符号数运算的文档。了解它们在特定情况下的默认选择,才能写出可预测、可移植的代码。
  4. 保持一致的数据流域:在一个模块内部,尽量让数据在同一个“域”内流动(要么全有符号,要么全无符号)。转换只在模块的输入和输出接口处进行。这能极大简化内部逻辑,减少错误。

有符号数和无符号数的转换,本质上是在数字的物理表示(位模式)和逻辑含义(数值)之间进行映射。它考验的是你对二进制系统、补码表示以及Verilog语言规则的透彻理解。没有捷径,多写、多仿、多踩坑,自然就能建立起准确的直觉。当你再看到$signed()$unsigned()时,你想到的不再是模糊的“类型转换”,而是清晰的“在此处,请将这段比特流按补码规则解读”或“在此处,请将其视为纯二进制正整数”的明确指令。