3. 数据类型与运算符:wire与reg详解、常量定义、位宽概念、算术/逻辑/移位运算符

好,咱们今天聊聊Verilog里最基础、也最容易搞混的两个东西——wirereg。还有常量怎么定义、位宽是怎么回事,以及那些天天用的运算符。

说实话,我刚开始学的时候,也纠结过“什么时候用wire,什么时候用reg”。后来踩了几个坑,才真正搞明白。今天我把这些经验都抖出来,你一次弄懂,后面写代码就顺了。

3.1 wire与reg:到底有啥区别?

先记住一句话:wire是连线,reg是变量

听起来简单吧?但实际用起来,很多人就晕了。

3.1.1 wire——物理连线

wire说白了就是一根导线。它不存储值,只是把两个端口连起来。你给它什么,它就传什么。

// 典型用法:模块端口连接
wire a, b;
wire sum;

assign sum = a ^ b;  // 用assign赋值,必须用wire

我个人习惯:只要是用assign连续赋值的,左边必须是wire。这是硬性规定,没得商量。

核心规则:

  • wire只能在assign语句或模块端口连接中被赋值
  • wire不能存储值,它是组合逻辑
  • wire的默认值是高阻态Z(多驱动时)或X(未驱动时)

3.1.2 reg——寄存器变量

reg不是真正的寄存器,它只是一个变量。它可以在always块里被赋值,并且能保持上一次的值。

// 典型用法:时序逻辑
reg [7:0] counter;

always @(posedge clk) begin
    if (rst)
        counter <= 8'd0;
    else
        counter <= counter + 1;
end

嗯,这里要注意:reg不一定综合成寄存器。如果你在always块里写的是组合逻辑(比如always @(*)),那reg综合出来就是连线,不是触发器。

我曾经踩过的坑:

有一次我写了一个always @(posedge clk)块,里面用了reg变量,但忘记给所有分支赋值。结果综合出来一堆锁存器(latch),面积大了30%。从那以后,我养成了习惯——时序逻辑里一定给reg赋默认值。

3.1.3 什么时候用哪个?

场景 用wire 用reg
assign连续赋值 ✅ 必须 ❌ 不行
always @(posedge clk) ❌ 不行 ✅ 必须
always @(*) ❌ 不行 ✅ 必须
模块输入端口 ✅ 默认 ❌ 不行
模块输出端口 ✅ 可以 ✅ 可以

你想想看,如果输出端口在always块里赋值,那就得声明成output reg。如果只是assign赋值,那就用output wire。这个区分很重要。

3.2 常量定义:parameter与localparam

写代码最忌讳“魔法数字”。比如counter <= counter + 8'd100,这个100是什么意思?没人知道。

所以我们要用常量定义。Verilog里有两个关键字:parameterlocalparam

3.2.1 parameter——可被外部修改

parameter可以在模块实例化时被覆盖。适合做可配置的参数。

// 定义
module counter #(
    parameter WIDTH = 8,
    parameter MAX_VAL = 255
) (
    input clk, rst,
    output reg [WIDTH-1:0] count
);

// 实例化时修改
counter #(
    .WIDTH(16),
    .MAX_VAL(65535)
) u_counter (...);

我个人习惯:所有可配置的常量都用parameter。比如数据位宽、FIFO深度、计数器最大值。

3.2.2 localparam——模块内部私有

localparam不能被外部修改。它只在本模块内有效。

// 定义状态机的状态
localparam IDLE   = 2'b00;
localparam READ   = 2'b01;
localparam WRITE  = 2'b10;
localparam DONE   = 2'b11;

reg [1:0] state;

为什么用localparam?因为状态机的状态值不应该被外部乱改。用localparam就是告诉别人:“这是我的内部秘密,你别碰。”

我的建议:

能用localparam就别用parameter。只有当你确定这个参数需要被外部配置时,才用parameter。这样代码更安全,也更容易维护。

3.3 位宽概念——别小看它

位宽,说白了就是数据有多少根线。比如[7:0]就是8位宽,能表示0~255。

但位宽问题,是新手最容易出bug的地方。我见过太多因为位宽不匹配导致的仿真错误。

3.3.1 位宽声明

reg [7:0] data_8bit;    // 8位宽,MSB是7,LSB是0
reg [0:7] data_rev;     // 8位宽,MSB是0,LSB是7(不常用)
wire [15:0] bus;        // 16位总线

我个人习惯用[高:低]的写法,也就是[7:0]。这样符合直觉——高位在左边,低位在右边。

3.3.2 位宽不匹配的后果

reg [7:0] a = 8'hFF;    // 255
reg [3:0] b;

assign b = a;  // b只取了低4位,变成4'hF

你看,a是255,b只有4位,结果b变成了15。这种错误在仿真里很难发现,因为工具不会报错,只是默默截断。

我曾经踩过的坑:

有一次做图像处理,一个像素值从12位截断到8位,结果图像颜色全乱了。查了两天才发现是位宽赋值时自动截断。从那以后,我所有跨位宽赋值都手动加&掩码或者用{}拼接。

3.3.3 位宽扩展

小位宽赋值给大位宽时,会自动扩展。无符号数补0,有符号数补符号位。

reg [3:0] small = 4'b1010;
reg [7:0] large;

assign large = small;  // large = 8'b0000_1010

3.4 运算符详解

运算符这部分,说白了就是数学和逻辑。但Verilog里有些细节,不注意就会出错。

3.4.1 算术运算符

运算符 含义 示例
+ 加法 a + b
- 减法 a - b
* 乘法 a * b
/ 除法 a / b
% 取模 a % b

注意:乘法和除法在FPGA里很贵。一个乘法器要消耗很多LUT。除法更夸张,综合出来可能几百个周期才能算完。

我一般这样处理:

  • 乘2的幂次:用左移 <<
  • 除2的幂次:用右移 >>
  • 非2的幂次:用IP核或者查找表

3.4.2 逻辑运算符

运算符 含义 示例
&& 逻辑与 a && b
|| 逻辑或 a || b
! 逻辑非 !a

逻辑运算符的结果只有0或1。它把操作数当作布尔值处理——非0就是真,0就是假。

reg [3:0] a = 4'b1010;
reg [3:0] b = 4'b0000;

// 逻辑运算
result1 = a && b;  // 1 && 0 = 0
result2 = a || b;  // 1 || 0 = 1
result3 = !a;      // !1 = 0

3.4.3 按位运算符

运算符 含义 示例
& 按位与 a & b
| 按位或 a | b
^ 按位异或 a ^ b
~ 按位取反 ~a

按位运算符是对每一位独立操作。结果位宽和操作数相同。

reg [3:0] a = 4'b1010;
reg [3:0] b = 4'b1100;

// 按位运算
result1 = a & b;   // 4'b1000
result2 = a | b;   // 4'b1110
result3 = a ^ b;   // 4'b0110
result4 = ~a;      // 4'b0101

逻辑 vs 按位,别搞混:

&&&完全不同。前者是逻辑与,结果只有0或1;后者是按位与,结果和操作数位宽相同。我见过有人用if (a && b)写成了if (a & b),仿真结果完全不对。

3.4.4 移位运算符

运算符 含义 示例
<< 左移 a << 2
>> 右移 a >> 2

移位运算符在FPGA里是零成本的。因为它只是重新连线,不消耗任何逻辑资源。

reg [7:0] a = 8'b0000_1010;  // 10

// 左移1位 = 乘2
result1 = a << 1;  // 8'b0001_0100 = 20

// 右移2位 = 除4
result2 = a >> 2;  // 8'b0000_0010 = 2

嗯,这里要注意:右移对于有符号数,是算术右移还是逻辑右移? Verilog默认是逻辑右移(补0)。如果你需要算术右移(补符号位),要用>>>运算符。

reg signed [7:0] a = 8'b1000_1010;  // -118

// 逻辑右移
result1 = a >> 2;   // 8'b0010_0010 = 34

// 算术右移
result2 = a >>> 2;  // 8'b1110_0010 = -30

3.5 知识体系结构图

下面这张图,把本章的知识点串起来了。你可以把它当作一个快速索引。

数据类型与运算符知识体系 数据类型 运算符 wire(连线) reg(变量) assign赋值,组合逻辑 默认值:Z或X always块赋值,可存储 可能综合成latch 常量定义 parameter(可配置) localparam(私有) 算术:+ - * / % 逻辑:&& || ! 乘法/除法消耗资源 结果只有0或1 按位:& | ^ ~ 移位:<< >> 逐位操作,结果同宽 零成本,重新连线 位宽概念:声明方式、自动截断、扩展规则

这张图把本章的知识点分成了两大块:数据类型和运算符。数据类型下面又分了wire、reg和常量定义。运算符分了算术、逻辑、按位和移位。位宽概念是贯穿始终的基础,所以放在最下面。

总结一下我的经验:

  • wire和reg的选择,记住“assign用wire,always用reg”就够了
  • 常量定义优先用localparam,只有需要外部配置时才用parameter
  • 位宽问题,多检查、多仿真,别相信自动截断
  • 运算符里,移位是最便宜的,乘除是最贵的

好了,这一章的内容就到这儿。你把这些基础打牢了,后面写代码就会顺手很多。


公众号:蓝海资料掘金营,微信deep3321