第四章:时序逻辑建模——D触发器、寄存器、移位寄存器、计数器的RTL实现

各位同学,欢迎来到第四章。

上一章我们聊了组合逻辑,说白了就是“当前输入决定当前输出”。但数字电路里,光有组合逻辑可不行。你想想看,如果所有电路都只跟当前状态有关,那“记忆”从哪来?状态怎么保存?

嗯,这就是时序逻辑的活儿了。

时序逻辑的核心,就是时钟。时钟像心跳一样,一拍一拍地驱动着整个芯片。所有状态的变化,都发生在时钟的边沿(上升沿或下降沿)。今天我们就从最基础的D触发器开始,一路讲到计数器。这些都是我平时写RTL时天天打交道的玩意儿。

本章核心知识点:

  • D触发器的RTL实现与工作原理
  • 寄存器的概念与多比特实现
  • 移位寄存器的几种玩法
  • 二进制计数器与模N计数器的设计
时序逻辑建模知识体系 时序逻辑 D触发器 寄存器 移位寄存器 计数器 同步复位 异步复位 流水线寄存器 配置寄存器 SISO/SIPO PISO/PIPO 二进制计数器 模N计数器

4.1 D触发器——时序逻辑的基石

D触发器,全称Data Flip-Flop,是时序逻辑里最基础的存储单元。它只有一个数据输入D,一个时钟输入clk,一个输出Q。在时钟上升沿,Q = D。

说白了,它就是把输入“拍”到输出上,延迟了一个时钟周期。

先看一个最简单的D触发器RTL代码:

module d_flip_flop (
    input  wire       clk,
    input  wire       d,
    output reg        q
);
    always @(posedge clk) begin
        q <= d;
    end
endmodule

注意这里用的是 always @(posedge clk),表示只在时钟上升沿触发。赋值用的是 <=(非阻塞赋值),这是时序逻辑的标准写法。我刚开始学的时候,老搞混阻塞和非阻塞,后来吃过一次仿真波形不对的亏,才彻底记住了——时序逻辑用非阻塞,组合逻辑用阻塞。

个人小技巧: 写RTL时,看到 always @(posedge clk) 就条件反射用 <=。养成肌肉记忆,能省很多debug时间。

实际项目中,D触发器通常还会带复位信号。复位分两种:同步复位和异步复位。

4.1.1 同步复位 vs 异步复位

同步复位:只在时钟上升沿检查复位信号。异步复位:复位信号一有效,立刻复位,不管时钟。

// 同步复位 D触发器
module dff_sync_rst (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       d,
    output reg        q
);
    always @(posedge clk) begin
        if (!rst_n)
            q <= 1'b0;
        else
            q <= d;
    end
endmodule

// 异步复位 D触发器
module dff_async_rst (
    input  wire       clk,
    input  wire       rst_n,
    input  wire       d,
    output reg        q
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            q <= 1'b0;
        else
            q <= d;
    end
endmodule

我曾经踩过的坑: 异步复位的敏感列表里必须把复位信号写全。有一次我漏写了 negedge rst_n,综合出来复位根本不起作用。仿真时看着波形发呆了好久才找到原因。

我个人习惯:能选异步复位就选异步复位。因为异步复位不依赖时钟,上电瞬间就能把电路拉到已知状态。但要注意,异步复位释放时不能离时钟沿太近,否则会出亚稳态——这个我们后面章节再细聊。

4.2 寄存器——D触发器的“多胞胎”

单个D触发器只能存1比特。那要存8比特、16比特呢?简单,把多个D触发器并排放,共用一个时钟和复位,就是寄存器了。

module reg_8bit (
    input  wire        clk,
    input  wire        rst_n,
    input  wire [7:0]  d,
    output reg  [7:0]  q
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            q <= 8'b0;
        else
            q <= d;
    end
endmodule

你看,代码几乎一模一样,只是位宽从1变成了8。这就是RTL设计的魅力——一旦掌握了基本单元,扩展起来非常自然。

寄存器在芯片里无处不在。我做过一个通信芯片,里面光配置寄存器就有上百个,每个寄存器控制着不同的模式、使能、状态。写RTL时,我习惯把寄存器地址和功能列成一个表格,方便自己和验证同事对照。

寄存器名称 地址 位宽 功能描述
CTRL_REG 0x00 8 控制寄存器:使能、模式选择
STATUS_REG 0x04 8 状态寄存器:中断标志、忙信号
DATA_REG 0x08 32 数据寄存器:收发数据缓存

4.3 移位寄存器——数据“动起来”

移位寄存器,说白了就是让数据在寄存器链里“搬家”。每个时钟周期,数据向右或向左移动一位。

最常见的应用是串并转换。比如一个串行数据进来,你想把它拼成一个字节,那就需要一个8位的移位寄存器。

module shift_reg_8bit (
    input  wire        clk,
    input  wire        rst_n,
    input  wire        sin,   // 串行输入
    output wire [7:0]  pout   // 并行输出
);
    reg [7:0] shift_reg;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            shift_reg <= 8'b0;
        else
            shift_reg <= {shift_reg[6:0], sin};
    end

    assign pout = shift_reg;
endmodule

这段代码里,{shift_reg[6:0], sin} 就是把原来的低7位往左移,新进来的sin塞到最低位。每个时钟周期,数据就向左“蠕动”一格。

移位寄存器还有几种变体:

  • SISO(串入串出):输入输出都是串行,就是一根线进一根线出。
  • SIPO(串入并出):上面那个例子就是,串行进,并行出。
  • PISO(并入串出):并行加载,然后串行移出去。SPI接口的发送端常用这个。
  • PIPO(并入并出):其实就是普通寄存器。

我的一点经验: 写移位寄存器时,注意移位方向。左移是乘2,右移是除2。如果你在做数字信号处理,移位寄存器经常用来实现延迟线或者FIR滤波器。我做过一个FIR滤波器,里面用了三级移位寄存器来对齐数据,那代码写起来特别规整。

4.4 计数器——数字电路的“节拍器”

计数器,本质上就是一个寄存器加上加法器。每个时钟周期加1,计到某个值就归零或者做其他操作。

先看一个最简单的二进制计数器:

module counter_8bit (
    input  wire        clk,
    input  wire        rst_n,
    output reg  [7:0]  count
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            count <= 8'b0;
        else
            count <= count + 1'b1;
    end
endmodule

这个计数器从0跑到255,然后溢出回0。简单吧?但实际项目中,我们很少用这种“裸奔”的计数器。更多时候需要模N计数器——计到N-1就归零。

// 模10计数器(0~9循环)
module counter_mod10 (
    input  wire        clk,
    input  wire        rst_n,
    output reg  [3:0]  count,
    output reg         carry
);
    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            count <= 4'b0;
            carry <= 1'b0;
        end
        else if (count == 4'd9) begin
            count <= 4'b0;
            carry <= 1'b1;
        end
        else begin
            count <= count + 1'b1;
            carry <= 1'b0;
        end
    end
endmodule

这个模10计数器,计到9的时候,下一个时钟归零,同时拉高carry信号。carry可以级联到下一个计数器,实现多位计数。

避坑指南: 我曾经在写计数器时,把比较条件写成了 count == 4'd10。你想想看,4位二进制最大只能表示15,计到10其实没问题,但逻辑上不对——我要的是0~9循环,计到9就该归零。写成10的话,计数器会跑到10才归零,多跑了一个数。这种bug仿真时很容易漏掉,因为波形上看10确实存在。所以,边界条件一定要反复确认。

计数器在芯片里太常用了:

  • 分频:把高频时钟分频成低频时钟
  • 定时:比如等待1000个时钟周期后触发某个操作
  • 地址生成:顺序访问存储器时,用计数器产生地址
  • 状态机索引:有限状态机里,用计数器记录停留的周期数

嗯,说到状态机,那是我们下一章的重点。计数器经常和状态机配合使用,比如在某个状态里等待N个周期再跳转。

4.5 小结

这一章我们聊了时序逻辑的四个基本模块:

  • D触发器:1比特存储,时序逻辑的细胞
  • 寄存器:多比特D触发器,数据暂存的主力
  • 移位寄存器:数据串并转换、延迟、对齐的好帮手
  • 计数器:分频、定时、地址生成,无处不在

这些模块的RTL写法其实都很固定,说白了就是 always @(posedge clk) 加上 <= 赋值。但真正用好它们,需要理解背后的硬件行为。我建议你每写完一个模块,都在脑子里过一遍:综合出来是什么电路?几个触发器?组合逻辑在哪里?

养成这个习惯,你的RTL设计水平会进步很快。

课后练习: 写一个8位的双向移位寄存器,支持左移、右移、并行加载、保持四种模式。控制信号用2位sel选择。试试看,写完后仿真一下波形。

可可精品资料库