【深度解析】时序逻辑与组合逻辑:FPGA设计的核心基础

摘要:本文深入探讨FPGA设计中两大核心逻辑类型——组合逻辑与时序逻辑,通过丰富的Verilog代码示例和实际应用场景,帮助读者掌握数字电路设计的基本原理。

叠甲作者也是一个刚踏入fpga开发不久的学生,有些地方可能写的不太好,如果有问题,我会虚心接受的,也欢迎大家在评论区讨论自己的疑问,我会尽力解答!!!


一、组合逻辑基础

1.1 什么是组合逻辑?

组合逻辑(Combinational Logic) 是指输出仅取决于当前输入的逻辑电路。它没有记忆功能,输入变化会立即反映在输出上。

生活化类比:想象一个简单的计算器按键操作。当你按下"1+1="时,结果"2"会立即显示出来。这种即时反应就是组合逻辑的典型特征。

1.2 组合逻辑的特点

  • 无记忆性:输出只与当前输入有关
  • 即时性:输入变化立即导致输出变化(但在实际设计中,这一过程其实并不是瞬时的,门电路有微小延迟,从寄存器到寄存器的一个过程也会有延时,但现在只需要简单的认为这一过程是瞬间的)
  • 无时钟依赖:不需要时钟信号同步
  • 逻辑函数:可以用布尔代数描述

1.3 常见的组合逻辑电路

电路类型功能描述典型应用
与门(AND)所有输入为1时输出1条件判断
或门(OR)任一输入为1时输出1多条件选择
非门(NOT)输入取反信号反转
编码器多输入转少输出键盘扫描
译码器少输入转多输出显示驱动
多路选择器多选一数据选择数据路由
全加器二进制加法器数字运算
半加器二进制加法器数字运算
移位寄存器数据存储和传输数据处理

1.4 组合逻辑的Verilog实现

组合逻辑主要使用assign语句实现:

// 示例1:2输入与门(组合逻辑)
module and_gate(
    input a, b,
    output y
);
    assign y = a & b;  // 即时计算,无时钟依赖
endmodule

// 示例2:4位加法器(组合逻辑)
module adder_4bit(
    input [3:0] a, b,
    output [3:0] sum,
    output cout
);
    assign {cout, sum} = a + b;  // 组合逻辑实现加法
endmodule

代码说明

  • assign语句用于实现组合逻辑
  • 输出立即响应输入变化
  • 不需要时钟信号同步

1.5 Verilog模块基础

module-endmodule是Verilog里的模块定义语句,用于定义一个模块的开始和结束。一个模块可以看作一块电路,包含输入信号、输出信号、中间信号以及逻辑门电路。

基本格式

module 模块名(
    input 输入信号1, 输入信号2, ...,
    output 输出信号1, 输出信号2, ...
);
    // 逻辑实现
endmodule

说明

  • 输入信号和输出信号可以有多个
  • 模块名应具有描述性,反映电路功能

关键语法要点:

  • 使用assign关键字
  • 输出立即响应输入变化
  • 不需要时钟信号

我认为其实真正有意思的并不是这种简单的一个加减乘除或者逻辑运算,而是这种能够组合起来实现复杂功能的电路。比如一个4位加法器,就是由4个全加器组成的,每个全加器都是一个组合逻辑电路,但是通过组合起来,就实现了一个4位加法器的功能。

就拿加法器举例,它是由全加器构成的,全加器又是由半加器构成的,半加器又是由与门、或门、非门等基本门电路构成的。
我觉得如果拿半加器来组合一个加法器可能就会更有意思。

学过数电的朋友们应该都知道半加器的一个逻辑实现是这样的:

S = A ⊕ B // 异或操作
C = A and B // 与操作

其中,S是输出的和位(Sum),C是输出的进位位,A和B是输入的被加数位和加数位。

那么在verilog里我们应该如何实现一个半加器呢?

module half_adder(
    input a, b,
    output sum, cout
);
    assign sum = a ^ b;  // 异或门实现和位 (^) 代表异或操作
    assign cout = a & b; // 与门实现进位位 (&) 代表与操作
endmodule

这样,我们就实现了一个半加器。
输入:a, b
输出:sum, cout

1.6 半加器逻辑图

半加器的逻辑结构可以用门电路表示如下:

A输入

异或门 XOR

B输入

与门 AND

和位 Sum

进位位 Cout

逻辑图说明

  • 异或门(XOR):计算和位 S = A ⊕ B
  • 与门(AND):计算进位位 C = A ∧ B
  • 这是最简单的加法器,只能处理一位加法,不考虑前一位的进位

那全加器是如何实现的呢?
我们知道半加器只能实现一位的加法,那如果要实现多位的加法,就需要用全加器来组合起来。
全加器的逻辑实现是这样的:

S = A ⊕ B ⊕ Ci // 三个元素的异或操作
Ci = A ∧ B ∨ (A ∧ Ci) ∨ (B ∧ Ci) // (A与B)或(A与Ci)或(B与Ci)

其中,S是输出的和位(Sum),Ci是输出的进位位,A和B是输入的被加数位和加数位,Ci是输入的前一位进位位。

也就是说,全加器就是由半加器和或门构成的。
我们可以用verilog来实现一个全加器:

module full_adder(
    input a, b, cin,
    output sum, cout
);
    assign sum = a ^ b ^ cin; // 三个元素的异或操作
    assign cout = (a & b) | (a & cin) | (b & cin); // (A与B)或(A与Ci)或(B与Ci)
endmodule

输入:a, b, cin
输出:sum, cout

其中,S是输出的和位(Sum),Cout是输出的进位位,A和B是输入的被加数位和加数位,Ci是输入的前一位进位位。

全加器逻辑图(门电路实现)

全加器可以用基本门电路实现,其逻辑结构如下:

A输入

异或门 XOR1

B输入

与门 AND1

异或门 XOR2

Ci输入

与门 AND2

或门 OR

和位 Sum

进位位 Cout

逻辑图说明:

  • 异或门链:XOR1计算A⊕B,XOR2计算(A⊕B)⊕Ci得到和位
  • 与门和或门:AND1计算A∧B,AND2计算(A⊕B)∧Ci,OR门合并进位信号
  • 全加器能够处理前一位的进位,实现完整的加法功能

看到这里,大家可能会有点疑惑,不是说全加器就是由半加器和或门构成的吗?
那为什么这里用的是异或门和或门呢?
那我们能不能就用刚刚写完好的半加器和或门来实现全加器呢?
答案是可以的,只是我们需要用半加器来实现和位,用或门来实现进位位。
下面是用半加器和或门实现全加器的代码:

module full_adder(
    input a, b, cin,
    output sum, cout
);
    wire s1, c1, c2;
    half_adder ha1(.a(a), .b(b), .sum(s1), .cout(c1)); // 半加器1
    half_adder ha2(.a(s1), .b(cin), .sum(sum), .cout(c2)); // 半加器2
    assign cout = c1 | c2; // 或门实现进位位 (|) 代表或操作
endmodule

输入:a, b, cin
输出:sum, cout

全加器逻辑图(半加器实现)

用半加器实现全加器的逻辑结构更加直观:

A输入

半加器1

B输入

Ci输入

半加器2

中间和 s1

进位1 c1

和位 Sum

进位2 c2

或门 OR

进位位 Cout

逻辑图说明:

  • 半加器1:计算A+B,得到中间和s1和进位c1
  • 半加器2:计算s1+Ci,得到最终和位Sum和进位c2
  • 或门:合并两个进位信号c1和c2得到最终进位Cout
  • 这种实现方式更清晰地展示了全加器由半加器组合而成的原理

至于多位加法器,你们可以自己去琢磨一下,例如怎么实现一个4位加法器

有时候我们需要通过一些判断逻辑再去改变寄存器的值,那么如何用组合逻辑来实现呢?

我们可以用三元运算符和assign来简单实现判断逻辑,例如:


wire a; // 常用的线网类型
assign a = a ? 1'b0 : 1'b1;

上述代码的意思是,如果a为1,那么a就赋值为0,否则就赋值为1。也就是一个来回反转的逻辑。
如果只用三元运算符的话,我们并不能简洁的实现一些比较复杂的判断逻辑,怎么解决呢?
我们可以在always块里使用"if-else","case-endcase"等等语句来实现判断逻辑
always是什么?块又是什么?
always是verilog语言里用于定义连续执行的代码块的关键词
而块,是代码块,包含多条语句,常见的有always块以及initial块.
但是我们平时都会写begin-end,这又是什么?大家可以把它看作是一个代码块的开始和结束标记,
在begin-end之间的语句会被顺序执行,就像我们在C语言里写的那样。
所以

always @() begin
    
end

这就是一个always块的基本结构,在always块里,我们可以使用"if-else",“case-endcase"等等语句来实现判断逻辑,
同时也可以使用赋值语句来改变寄存器的值。
那么,又一个问题来了,”@()"是什么?
"@()"是一个事件敏感列表,用于指定always块在哪些信号发生变化时才会执行。
例如

wire a;
always @(a) begin
    
end

当a发生变化时,always块里的语句就会被执行一次。

wire a, b;
always @(a or b) begin
    // 当a或b发生变化时,执行以下语句
    // 例如,计算a+b的结果
    sum = a + b;
end

当a或b发生变化时,always块里的语句就会被执行一次。

如果一个项目比较复杂,信号比较多,我们也可以直接在括号里填"*"

always @(*) begin
    // 当任何输入信号发生变化时,执行以下语句
    // 例如,计算a+b的结果
    sum = a + b;
end

这样也会包含a,b的变化,当a或b发生变化时,always块里的语句就会被执行一次。
当然,其他信号的变化也会触发该always块的执行,但是并不影响.

这样的话我们就可以开始写一个简单的3-8译码器了
3-8译码器的功能是,当输入信号in为3位二进制数时(可看作一个十进制数,从0到7),输出信号out为8位二进制数,
真值表如下

输入 in(3 位)十进制值输出 out(8 位,低电平有效)
3’b00008’b11111110
3’b00118’b11111101
3’b01028’b11111011
3’b01138’b11110111
3’b10048’b11101111
3’b10158’b11011111
3’b11068’b10111111
3’b11178’b01111111
module decoder_3_8(
    input [2:0] in,
    output reg [7:0] out
);
    always @(*) begin
        case(in)
            3'b000: out = 8'b11111110;
            3'b001: out = 8'b11111101;
            3'b010: out = 8'b11111011;
            3'b011: out = 8'b11110111;
            3'b100: out = 8'b11101111;
            3'b101: out = 8'b11011111;
            3'b110: out = 8'b10111111;
            3'b111: out = 8'b01111111;
        endcase
    end
endmodule

这是一个通过case语句实现的3-8译码器,当输入信号in为3位二进制数时,输出信号out为8位二进制数,

除了使用case语句,我们还可以使用if-else语句来实现3-8译码器,例如:

module decoder_3_8(
    input [2:0] in,
    output reg [7:0] out
);
    always @(*) begin
        if(in == 3'b000) out = 8'b11111110;
        else if(in == 3'b001) out = 8'b11111101;
        else if(in == 3'b010) out = 8'b11111011;
        else if(in == 3'b011) out = 8'b11110111;
        else if(in == 3'b100) out = 8'b11101111;
        else if(in == 3'b101) out = 8'b11011111;
        else if(in == 3'b110) out = 8'b10111111;
        else if(in == 3'b111) out = 8'b01111111;
    end
endmodule

这是一个通过if-else语句实现的3-8译码器,当输入信号in为3位二进制数时,输出信号out为8位二进制数,

写到这里我又想起之前我在学习3-8译码器的时候我犯过的一个错误
当时我是这样写的

module decoder_3_8(
    input [2:0] in,
    output reg [7:0] out
);
    always @(*) begin
        out = ~(8'b00000001 << in);
    end
endmodule

其中波浪号(~)是按位取反运算符,用于将一个二进制数的每一位取反,例如

~8'b00000001 = 8'b11111110

大家也可以看看这行代码的执行过程

out = ~(8'b00000001 << in);

当in为3’b000时,

out = ~(8'b00000001 << 3'b000);
out = ~(8'b00000001 << 0);
out = ~(8'b00000001);
out = 8'b11111110;

当in为3’b001时,

out = ~(8'b00000001 << 3'b001);
out = ~(8'b00000010);
out = 8'b11111101;

当in为3’b010时,

out = ~(8'b00000001 << 3'b010);
out = ~(8'b00000010 << 2);
out = ~(8'b00000100);
out = 8'b11111011;

输出结果与真值表一致。但这样写是错误的,这样的话,是实现的一个移位寄存器,而不是3-8译码器。
大家也可以去了解一下他们的具体区别,具体是用来干嘛的。

说了这么多,组合逻辑在实际开发中到底有什么用呢?
首先,显而易见的,我们用它来实现一些简单的逻辑功能,例如加法器、减法器、乘法器、除法器、比较器、编码器、译码器等。
它不会傻傻的去等待时钟信号,而是立即响应输入信号的变化,输出结果也会立即反映输入信号的变化。
除了进行算术运算,组合逻辑还可以用于状态机的跳转.当时序逻辑中改变某些flag时,组合逻辑可以根据当前的状态和输入信号,立即计算出下一个状态和输出信号。在下一个时钟周期,时序逻辑就会根据计算出的下一个状态和输出信号,来更新寄存器的值。


二、时序逻辑基础

2.1 什么是时序逻辑?

时序逻辑(Sequential Logic)是指输出不仅取决于当前输入,还取决于电路的历史状态的逻辑电路。它具有记忆功能,需要时钟信号来同步操作。

时序逻辑中我们也会见到熟悉的"always块",但语法有所不同:

module counter(
    input clk, rst
);
    always @(posedge clk or negedge rst) begin
        if(!rst)
            // 复位逻辑
        else
            // 正常计数逻辑
    end
endmodule

关键变化:敏感序列变成了posedge clk or negedge rst,即:

  • clk的上升沿
  • rst的下降沿

2.2 时序逻辑的工作原理

在FPGA中,时序逻辑的触发是通过D触发器实现的:

  • 时钟信号clk是同步信号,控制电路操作的节奏
  • 复位信号rst用于初始化电路状态
  • 信号保持:触发时,FPGA内部信号维持在当前时刻的上一瞬间

示例说明
假设变量a当前值为1,当clk上升沿到来时:

  1. 进入always
  2. a进行操作(如a = a + 1
  3. 但此时a的值仍为1(维持上一瞬间状态)
  4. 只有当下一个clk上升沿到来时,a才会更新为2

2.3 复位机制

我习惯将rst设为高电平有效,当需要复位时将rst拉低:

  • 当捕捉到rst的下降沿时,rst的值仍为高电平
  • 复位操作在下降沿触发

2.4 计数器示例

时序逻辑中最常见的应用就是计数器:

module counter(
    input clk, rst,
    output reg [3:0] count  // reg寄存器:在时序逻辑中必须使用reg变量
);
    always @(posedge clk or negedge rst) begin
        if(!rst)
            count <= 4'b0000; // 复位时,将计数器的值设为0
        else
            count <= count + 1; // 每个clk上升沿,计数器加1
    end
endmodule

代码分析

  • 这是一个简单的4位计数器
  • 在每个clk上升沿时,计数器值加1
  • rst为低电平时,计数器复位为0

时序计算
如果clk的频率是50MHz:

  • 每个clk周期:20ns
  • 每个上升沿间隔:20ns
  • 计数器加1频率:每20ns加1

2.5 阻塞赋值与非阻塞赋值

在时序逻辑的always块中,我们使用<=而非=

  • <=:非阻塞赋值运算符,用于寄存器变量
  • =:阻塞赋值运算符,用于线网变量

重要规则:在时序逻辑中必须使用reg变量,不能使用wire类型。

阻塞赋值示例
a = 1; b = 2; c = 3;
a = b;  // a = 2
b = c;  // b = 3
a = a + b; // a = 5

特点:顺序执行,每执行完一条语句就更新变量值

非阻塞赋值示例
a = 1; b = 2; c = 3;

begin
    a <= b;   
    b <= c;  
    c <= a + b; // c = 1 + 2 = 3
end

特点begin-end块内的语句同时执行

注意:不能对同一变量进行多次非阻塞赋值

// 错误示例
begin
    a <= b;   
    a <= a + b; // 同时两次赋值,会报错
end

回归正题,了解了时序逻辑了之后
我们就可以实现一个简单的定时器,实现每隔相同的时间,执行某些操作。
例如,我想实现一个简单的定时器,每隔1s,就将一个led灯切换一次状态,
我可以这样写

2.6 实际应用:LED闪烁定时器

了解了时序逻辑后,我们可以实现一个简单的定时器,实现周期性操作。例如,实现LED每隔1秒闪烁一次:

module led_blink(
    input clk, rst,
    output reg led
);
    reg [24:0] count; // 25位计数器,用于计数1秒
    always @(posedge clk or negedge rst) begin
        if(!rst) begin
            count <= 25'b0; // 复位时,将计数器的值设为0
        end else if(count == 25'd49_999_999) begin // 计数到49999999时切换LED状态
            count <= 25'd0; // 计数器清零
            led <= ~led; // 切换LED状态
        end else begin
            count <= count + 1; // 每个clk上升沿,计数器加1
        end
    end
endmodule

时序分析

  • 从0计数到49999999:共50000000次
  • 每个clk周期:20ns(50MHz时钟)
  • 总时间:50000000 × 20ns = 1秒

2.7 时序精度问题与解决方案

由于FPGA的时序特性,上述实现存在精度问题:

  • 当计数器计数到49999999时,需要等待下一个clk上升沿才能切换LED状态
  • 实际定时时间为:1秒 + 1个clk周期

解决方案1:提前计数周期

module led_blink(
    input clk, rst,
    output reg led
);
    reg [24:0] count;
    always @(posedge clk or negedge rst) begin
        if(!rst) begin
            count <= 25'b0;
        end else if(count == 25'd49_999_998) begin // 提前一个周期
            count <= 25'd0;
            led <= ~led;
        end else begin
            count <= count + 1;
        end
    end
endmodule

解决方案2:结合组合逻辑优化

module led_blink(
    input clk, rst,
    output wire led
);
    reg [24:0] count;
    always @(posedge clk or negedge rst) begin
        if(!rst) begin
            count <= 25'b0;
        end else if(count == 25'd49_999_999) begin
            count <= 25'd0;
        end else begin
            count <= count + 1;
        end
    end
    assign led = (count == 25'd49_999_999) ? 1'b0 : 1'b1; // 组合逻辑立即赋值
endmodule

优势

  • 当计数到49999999时,组合逻辑会立即对LED赋值
  • 不需要等待下一个clk周期
  • 实现更精确的定时控制

2.8 状态机设计思想

状态机设计也遵循类似原则:

  • 当状态机到达某个状态时,立即对输出信号赋值
  • 避免等待下一个时钟周期
  • 提高响应速度和时序精度

三、总结

3.1 组合逻辑与时序逻辑对比

特性组合逻辑时序逻辑
记忆功能
时钟依赖不需要需要
响应速度立即响应时钟同步
实现方式assign语句always
赋值方式=(阻塞)<=(非阻塞)

3.2 实际应用建议

  1. 组合逻辑适用场景

    • 算术运算(加法器、乘法器等)
    • 逻辑判断(编码器、译码器等)
    • 数据路由(多路选择器等)
  2. 时序逻辑适用场景

    • 计数器、定时器
    • 状态机
    • 数据存储(寄存器、FIFO等)
  3. 混合设计技巧

    • 组合逻辑用于即时计算
    • 时序逻辑用于状态保持
    • 两者结合实现复杂功能

3.3 学习建议

  • 从简单电路开始,逐步增加复杂度
  • 理解阻塞与非阻塞赋值的区别
  • 掌握时钟域和复位机制
  • 实践项目驱动学习

等待下一个clk周期

  • 实现更精确的定时控制

2.8 状态机设计思想

状态机设计也遵循类似原则:

  • 当状态机到达某个状态时,立即对输出信号赋值
  • 避免等待下一个时钟周期
  • 提高响应速度和时序精度

三、总结

3.1 组合逻辑与时序逻辑对比

特性组合逻辑时序逻辑
记忆功能
时钟依赖不需要需要
响应速度立即响应时钟同步
实现方式assign语句always
赋值方式=(阻塞)<=(非阻塞)

3.2 实际应用建议

  1. 组合逻辑适用场景

    • 算术运算(加法器、乘法器等)
    • 逻辑判断(编码器、译码器等)
    • 数据路由(多路选择器等)
  2. 时序逻辑适用场景

    • 计数器、定时器
    • 状态机
    • 数据存储(寄存器、FIFO等)
  3. 混合设计技巧

    • 组合逻辑用于即时计算
    • 时序逻辑用于状态保持
    • 两者结合实现复杂功能

3.3 学习建议

  • 从简单电路开始,逐步增加复杂度
  • 理解阻塞与非阻塞赋值的区别
  • 掌握时钟域和复位机制
  • 实践项目驱动学习

结语:组合逻辑和时序逻辑是FPGA设计的基石,掌握它们的原理和应用是成为优秀数字电路工程师的关键。通过本文的学习,希望您能够更好地理解和运用这两种逻辑类型,为后续的FPGA开发打下坚实基础。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐