3、RTL设计基础回顾:Verilog/SV核心语法、组合逻辑与时序逻辑、状态机设计、代码风格与规范

好,咱们正式开始聊RTL设计。很多刚入行的朋友觉得Verilog就是一堆always块和assign语句,能跑就行。但说实话,在IP集成这个领域,代码写得漂不漂亮,直接决定了你后面调试要加多少班。我自己带过的项目里,因为代码风格混乱导致集成阶段反复返工的案例,一只手都数不过来。

这一章,咱们把RTL设计的基础再过一遍。重点不是教你怎么写Hello World,而是把那些在IP集成中真正要命的知识点拎出来讲透。

3.1 Verilog/SV核心语法:够用就行,别炫技

Verilog和SystemVerilog的语法书能写几百页,但咱们做IP集成的,真正高频使用的就那么几个。我个人的习惯是:能用SV的地方尽量用SV,毕竟它在数据类型和接口封装上确实方便太多。

3.1.1 数据类型:reg和wire的“老黄历”

很多教科书还在强调reg和wire的区别——reg被综合成寄存器,wire被综合成连线。嗯,这个说法在早期的工具里确实成立,但现在早就不准确了。

你想想看,一个reg变量在always @(*)块里赋值,综合出来就是纯组合逻辑,跟wire没区别。所以我的建议是:别纠结reg和wire,直接统一用logic。SystemVerilog里的logic类型,既能当reg用,也能当wire用,省心。

核心原则:

  • 组合逻辑输出 → 用logic,在always_comb里赋值
  • 时序逻辑输出 → 用logic,在always_ff里赋值
  • 模块间连线 → 用logic或wire都行,我习惯用logic

3.1.2 always块的三种写法

这块是新手最容易翻车的地方。always块有三种常用风格,每种对应不同的硬件行为:

写法 敏感列表 综合结果 典型场景
always_comb 自动推断 组合逻辑 译码器、多路选择器
always_ff @(posedge clk) 时钟沿 时序逻辑(触发器) 计数器、移位寄存器
always_latch 电平敏感 锁存器 尽量避免使用

我曾经在集成一个SPI控制器时,发现从机模式下数据总是少采一拍。查了半天,发现是同事在always_comb里漏写了else分支,综合工具默默给你插了个latch。嗯,从那以后我要求团队:组合逻辑必须写全所有分支,否则用default兜底

避坑指南: 我曾经见过一个项目,因为always_comb里漏了敏感信号,导致仿真和综合结果不一致。仿真时信号变化能触发,但综合工具只认你写进敏感列表里的信号。所以,用always_comb代替always @(*),让工具自动推断敏感列表,少一个隐患。

3.2 组合逻辑与时序逻辑:分清楚,别混用

这个区分是RTL设计的基石。说白了,组合逻辑就是“输入一变,输出立刻变”;时序逻辑就是“等到时钟沿来了,输出才变”。

3.2.1 组合逻辑设计要点

组合逻辑的核心是无状态、无记忆。写代码时要注意:

  • 赋值必须完整:同一个变量不能在多个always块里赋值,否则综合会报多驱动错误
  • 避免反馈环路:组合逻辑的输出不能直接或间接反馈到输入,否则会形成振荡
  • 使用阻塞赋值(=):组合逻辑里用阻塞赋值,时序逻辑里用非阻塞赋值(<=),这是铁律

举个例子,一个简单的4选1多路选择器:

module mux4to1 (
    input  logic [1:0] sel,
    input  logic [3:0] data_in,
    output logic       data_out
);
    always_comb begin
        case (sel)
            2'b00:   data_out = data_in[0];
            2'b01:   data_out = data_in[1];
            2'b10:   data_out = data_in[2];
            2'b11:   data_out = data_in[3];
            default: data_out = 1'b0;  // 兜底,避免latch
        endcase
    end
endmodule

你看,这里用了always_comb,case写全了,default也给了。这就是规范的组合逻辑写法。

3.2.2 时序逻辑设计要点

时序逻辑的核心是时钟沿触发。写代码时要注意:

  • 同步复位 vs 异步复位:我建议能用同步复位就用同步复位,异步复位虽然省资源,但容易引起复位释放时的时序问题
  • 非阻塞赋值(<=):时序逻辑里必须用非阻塞赋值,否则仿真结果会和实际硬件行为不一致
  • 避免组合逻辑和时序逻辑混用:同一个always块里,要么全是组合逻辑,要么全是时序逻辑,别混着写

一个带同步复位的8位计数器:

module counter_8bit (
    input  logic       clk,
    input  logic       rst_n,
    input  logic       en,
    output logic [7:0] count
);
    always_ff @(posedge clk) begin
        if (!rst_n)
            count <= 8'b0;
        else if (en)
            count <= count + 1'b1;
        // 注意:这里没有else,count保持原值,这是时序逻辑的“记忆”特性
    end
endmodule

个人经验: 我在集成一个I2C控制器时,发现SCL时钟线上总是出现毛刺。查到最后,是同事在时序逻辑里用了阻塞赋值,导致仿真通过但实际硬件行为异常。从那以后,我要求所有时序逻辑的always块第一行就写// 非阻塞赋值的注释,时刻提醒自己。

3.3 状态机设计:FSM是IP集成的灵魂

状态机在IP集成里太常见了——总线协议控制、数据流调度、握手信号管理,几乎每个IP核里都有FSM。我个人习惯把状态机分成三类:

  • Moore型:输出只取决于当前状态,与输入无关
  • Mealy型:输出取决于当前状态和输入
  • 混合型:实际项目中用得最多,既有Moore也有Mealy的特点

3.3.1 三段式状态机写法

我强烈推荐三段式状态机写法。为什么?因为可读性强、综合结果好、调试方便。三段分别是:

  1. 第一段:时序逻辑,描述状态跳转(当前状态 → 次态)
  2. 第二段:组合逻辑,描述次态生成逻辑(根据输入和当前状态计算次态)
  3. 第三段:组合逻辑或时序逻辑,描述输出生成

来看一个简单的UART接收状态机示例:

module uart_rx_fsm (
    input  logic       clk,
    input  logic       rst_n,
    input  logic       rx_bit,      // 串行输入
    input  logic       bit_done,    // 一个bit接收完成
    output logic       data_valid,  // 数据有效标志
    output logic [7:0] rx_data      // 接收到的数据
);

    // 状态定义
    typedef enum logic [1:0] {
        IDLE    = 2'b00,
        START   = 2'b01,
        DATA    = 2'b10,
        STOP    = 2'b11
    } state_t;

    state_t current_state, next_state;
    logic [2:0] bit_cnt;  // 记录接收了多少个数据bit

    // 第一段:状态跳转(时序逻辑)
    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            current_state <= IDLE;
        else
            current_state <= next_state;
    end

    // 第二段:次态生成(组合逻辑)
    always_comb begin
        next_state = current_state;  // 默认保持
        case (current_state)
            IDLE: begin
                if (rx_bit == 1'b0)  // 检测到起始位
                    next_state = START;
            end
            START: begin
                if (bit_done)
                    next_state = DATA;
            end
            DATA: begin
                if (bit_done && (bit_cnt == 3'd7))  // 8个数据位收完
                    next_state = STOP;
            end
            STOP: begin
                if (bit_done)
                    next_state = IDLE;
            end
            default: next_state = IDLE;
        endcase
    end

    // 第三段:输出生成(这里用时序逻辑,输出会晚一个时钟周期,但更稳定)
    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            data_valid <= 1'b0;
            rx_data    <= 8'b0;
            bit_cnt    <= 3'b0;
        end else begin
            case (current_state)
                IDLE: begin
                    data_valid <= 1'b0;
                    bit_cnt    <= 3'b0;
                end
                DATA: begin
                    if (bit_done) begin
                        rx_data <= {rx_bit, rx_data[7:1]};  // 移位接收
                        bit_cnt <= bit_cnt + 1'b1;
                    end
                end
                STOP: begin
                    data_valid <= 1'b1;  // 一个字节接收完成
                end
                default: begin
                    data_valid <= 1'b0;
                end
            endcase
        end
    end

endmodule

为什么推荐三段式?

  • 状态跳转和输出生成分离,调试时一眼就能看出问题在哪
  • 综合工具对三段式的优化效果最好,不容易产生冗余逻辑
  • 代码可读性强,团队协作时别人能快速理解你的设计意图

3.3.2 状态机设计避坑

我曾经在集成一个DMA控制器时,状态机里漏写了某个状态的default分支。结果在特定条件下,状态机跳到了一个未定义状态,整个DMA卡死了。从那以后,我给自己定了个规矩:

  • 每个case语句必须有default,哪怕你觉得所有分支都写全了
  • 状态编码用独热码(one-hot)或格雷码,别用二进制编码。独热码综合面积大但速度快,格雷码适合跨时钟域
  • 状态机输出要寄存,避免组合逻辑输出直接驱动外部信号,容易产生毛刺

3.4 代码风格与规范:好代码是“读”出来的

代码风格这东西,很多人觉得是“软实力”,不重要。但我在实际项目里吃过太多亏了——代码写得乱,集成的时候根本没法复用,最后只能重写。所以,我总结了几条铁律:

3.4.1 命名规范

类型 推荐命名 示例 说明
模块名 小写+下划线 uart_rx, spi_master 清晰表达功能
信号名 小写+下划线 data_valid, addr_bus 避免缩写,除非是通用缩写
参数/常量 大写+下划线 DATA_WIDTH, CLK_FREQ 一眼就能看出是常量
状态名 大写字母 IDLE, START, DATA 与变量区分开

3.4.2 代码布局规范

我个人的习惯是:每个模块的代码按以下顺序排列,就像写文章有“引言-正文-结论”一样:

  1. 模块头注释:功能描述、作者、修改历史
  2. 参数定义:所有可配置的参数集中放在模块开头
  3. 端口声明:输入在前,输出在后,按功能分组
  4. 内部信号声明:wire和reg分开,按功能分组
  5. 功能实现:按模块功能块划分,每个功能块用注释分隔

举个例子:

// ============================================================
// 模块功能:SPI主机控制器,支持模式0和模式3
// 作者:张三
// 修改历史:
//   2024-01-10 v1.0 初始版本
//   2024-03-15 v1.1 修复时钟极性反转问题
// ============================================================
module spi_master #(
    parameter DATA_WIDTH = 8,
    parameter CLK_DIV    = 4
) (
    input  logic              clk,
    input  logic              rst_n,
    input  logic              start,
    input  logic [DATA_WIDTH-1:0] tx_data,
    output logic              sclk,
    output logic              mosi,
    input  logic              miso,
    output logic [DATA_WIDTH-1:0] rx_data,
    output logic              done
);

    // 内部信号声明
    logic [DATA_WIDTH-1:0] shift_reg;
    logic [3:0]            bit_cnt;
    logic                  sclk_en;

    // ----------------------------------------------------------
    // 功能块1:时钟分频
    // ----------------------------------------------------------
    // ... 代码实现

    // ----------------------------------------------------------
    // 功能块2:数据移位
    // ----------------------------------------------------------
    // ... 代码实现

endmodule

小技巧: 我习惯在每个always块前面加一行注释,说明这个块的功能和输入输出。比如:// 组合逻辑:根据当前状态和输入计算次态。这样别人看代码时,不用一行一行去猜你在干什么。

3.4.3 综合与仿真的一致性

这是最容易被忽视的一点。很多新手写的代码,仿真能过,但综合出来功能不对。常见的原因有:

  • 敏感列表不完整:always @(*)里漏了信号,仿真时靠事件驱动能触发,但综合工具只认你写进去的信号
  • 阻塞赋值和非阻塞赋值混用:同一个always块里既有=又有<=,仿真结果和综合结果可能不一致
  • 循环语句使用不当:for循环在综合时会被展开,如果循环次数不是常数,综合会报错

我的建议是:写完代码后,先用lint工具跑一遍。像SpyGlass、Design Compiler的lint检查,能帮你发现90%以上的低级错误。别等到集成阶段再回头改,那时候改一个信号名都可能牵一发动全身。

3.5 本章小结

好了,这一章的内容就到这里。咱们回顾一下重点:

  • 语法够用就行:logic代替reg/wire,always_comb和always_ff规范使用
  • 组合逻辑和时序逻辑严格区分:阻塞赋值vs非阻塞赋值,别混用
  • 状态机用三段式:可读性强、综合好、调试方便
  • 代码风格是生产力:命名规范、布局清晰、注释到位

下一章,咱们要进入真正的IP集成实战了——从零开始搭建一个AMBA AHB总线接口。到时候你会看到,今天讲的这些基础,每一个都会用到。嗯,做好准备。