第3章 RTL设计基础:Verilog HDL核心语法回顾,组合逻辑与时序逻辑设计,状态机设计范式

各位同学,欢迎来到第三章。这一章咱们聊聊RTL设计的基础。说白了,就是Verilog语法、组合逻辑、时序逻辑,还有状态机。这些东西是MCU设计的砖瓦,你想想看,没有它们,再牛的架构也搭不起来。

我个人习惯,在开始写代码之前,先把这些基础过一遍。不是为了背语法,而是为了理解硬件思维。嗯,这里要注意,Verilog不是C语言,你写的是电路,不是软件。

3.1 Verilog核心语法回顾

Verilog的语法其实不多,常用的就那么几个。我刚开始学的时候,总觉得要记住所有关键字,后来发现完全没必要。你只要掌握模块声明、端口定义、赋值语句、过程块,基本就能干活了。

3.1.1 模块与端口

模块是Verilog的基本单元。每个模块对应一个硬件模块。端口就是模块的输入输出。

module counter (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       en,
    output reg  [7:0] count
);
    // 内部逻辑
endmodule

这里有个小细节:input默认是wire类型,output可以是wire或reg。我建议你养成好习惯,明确写出类型,别偷懒。我在项目中遇到过,有人不写类型,结果综合出来一堆奇怪的锁存器。

3.1.2 赋值语句

Verilog有三种赋值方式:连续赋值、阻塞赋值、非阻塞赋值。这是新手最容易搞混的地方。

  • 连续赋值(assign):用于组合逻辑,赋值符号是=。比如assign sum = a + b;
  • 阻塞赋值(=):用于组合逻辑的always块内。赋值立即生效。
  • 非阻塞赋值(<=):用于时序逻辑的always块内。赋值在块结束时统一更新。

核心原则:时序逻辑用非阻塞赋值,组合逻辑用阻塞赋值。别混用,否则仿真和综合结果不一致。

我曾经有个项目,一个同事在时序逻辑里用了阻塞赋值,结果仿真跑得好好的,流片回来功能全乱。查了三天才发现是赋值方式的问题。从那以后,我每次代码评审都盯着这个看。

3.1.3 过程块:always

always块是Verilog的灵魂。它有两种敏感列表:

  • 电平敏感:用于组合逻辑,比如always @(*)
  • 边沿敏感:用于时序逻辑,比如always @(posedge clk or negedge rst_n)

我个人习惯,组合逻辑用always @(*),时序逻辑用always @(posedge clk)。复位信号单独处理,别混在一起。

3.2 组合逻辑设计

组合逻辑,说白了就是没有记忆功能的电路。输出只取决于当前输入。常见的组合逻辑有加法器、多路选择器、译码器等。

3.2.1 组合逻辑的写法

组合逻辑有两种写法:

  1. assign语句:适合简单的逻辑。
  2. always @(*) 块:适合复杂的逻辑。
// 方式一:assign
assign sel = (a > b) ? a : b;

// 方式二:always @(*)
always @(*) begin
    if (a > b)
        sel = a;
    else
        sel = b;
end

小技巧:写组合逻辑时,一定要覆盖所有分支。否则综合会生成锁存器。我建议你用defaultelse兜底。

3.2.2 常见组合逻辑示例

咱们看一个4选1多路选择器:

module mux4 (
    input  [1:0] sel,
    input  [3:0] in0, in1, in2, in3,
    output reg [3:0] out
);
    always @(*) begin
        case (sel)
            2'b00: out = in0;
            2'b01: out = in1;
            2'b10: out = in2;
            2'b11: out = in3;
            default: out = 4'b0;  // 兜底
        endcase
    end
endmodule

嗯,这里要注意,case语句一定要写default。我曾经见过有人不写default,结果综合出来一堆锁存器,面积大了30%。

3.3 时序逻辑设计

时序逻辑有记忆功能,输出不仅取决于当前输入,还取决于历史状态。常见的时序逻辑有寄存器、计数器、移位寄存器等。

3.3.1 时序逻辑的写法

时序逻辑必须用always @(posedge clk)块,并且用非阻塞赋值。

always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        count <= 8'b0;
    else if (en)
        count <= count + 1'b1;
    else
        count <= count;
end

警告:时序逻辑里不要用阻塞赋值。否则仿真结果和实际电路不一致。我刚开始做设计时也犯过这个错,后来被导师骂了一顿才记住。

3.3.2 同步复位 vs 异步复位

复位方式有两种:

复位方式 特点 适用场景
同步复位 只在时钟上升沿复位 对复位时序要求严格的场景
异步复位 复位信号立即生效 需要快速复位的场景

我个人习惯用异步复位、同步释放。这样既保证了复位速度,又避免了亚稳态问题。你想想看,如果复位信号在时钟边沿附近变化,异步复位可能会出问题。

3.4 状态机设计范式

状态机是MCU控制逻辑的核心。说白了,就是让电路记住当前状态,根据输入决定下一步去哪。

3.4.1 状态机的分类

状态机分两种:

  • Moore型:输出只取决于当前状态。
  • Mealy型:输出取决于当前状态和输入。

我在项目中,控制逻辑多用Moore型,因为输出稳定,不容易出毛刺。数据通路偶尔用Mealy型,可以减少状态数。

3.4.2 三段式状态机

我强烈推荐三段式状态机。它把状态转移、次态逻辑、输出逻辑分开写,代码清晰,综合结果也好。

// 第一段:状态寄存器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        state <= IDLE;
    else
        state <= next_state;
end

// 第二段:次态逻辑
always @(*) begin
    case (state)
        IDLE:   if (start) next_state = READ;
                else       next_state = IDLE;
        READ:   if (done)  next_state = WRITE;
                else       next_state = READ;
        WRITE:  next_state = IDLE;
        default: next_state = IDLE;
    endcase
end

// 第三段:输出逻辑
always @(*) begin
    case (state)
        IDLE:   out = 2'b00;
        READ:   out = 2'b01;
        WRITE:  out = 2'b10;
        default: out = 2'b00;
    endcase
end

核心要点:第一段用非阻塞赋值,第二段和第三段用阻塞赋值。这样写出来的状态机,综合后面积小、时序好。

我曾经有个项目,用两段式状态机写了一个复杂的控制逻辑,结果综合出来时序不满足。改成三段式后,问题就解决了。你想想看,三段式把组合逻辑和时序逻辑分开了,综合工具更容易优化。

3.4.3 状态机编码方式

状态编码有三种常见方式:

编码方式 特点 适用场景
二进制编码 状态数少,面积小 状态数多的场景
格雷码 相邻状态只变一位,功耗低 低功耗场景
独热码 每个状态一个触发器,速度快 状态数少的场景

我个人习惯,状态数少于8个用独热码,多于8个用二进制编码。独热码虽然面积大,但译码逻辑简单,时序好。我在MCU设计中,控制状态机一般用独热码,因为状态数不多,但时序要求高。

3.5 避坑指南

最后,我总结几个常见坑,你们写代码时注意:

  • 锁存器:组合逻辑没写全分支,综合出锁存器。解决办法:always块里写default或else。
  • 赋值混用:时序逻辑用阻塞赋值,仿真和综合不一致。解决办法:时序逻辑统一用非阻塞赋值。
  • 敏感列表不全:组合逻辑的always块没写@(*),漏了信号。解决办法:一律用@(*)。
  • 状态机死锁:状态机没有default状态,跑到非法状态出不来。解决办法:case语句写default,回到IDLE。

嗯,这一章的内容就这些。你们回去把代码敲一遍,跑个仿真看看。下一章咱们聊RTL编码规范,到时候我会分享一些SMIC 40nm工艺下的设计经验。