第三章 Verilog基础与流水灯:组合逻辑与时序逻辑,模块化设计
各位同学,欢迎来到第三章。这一章我们开始真正动手写代码了。
说实话,很多初学者一上来就被Verilog的语法吓到了。什么assign、always、阻塞赋值、非阻塞赋值……搞得一头雾水。我当年也是这样,看了三天书,脑子里还是一团浆糊。后来我发现,其实你只要搞明白两件事——组合逻辑和时序逻辑,剩下的都是套路。
3.1 组合逻辑 vs 时序逻辑:本质区别
先问一个问题:一个数字电路,如果输入变了,输出会不会立刻跟着变?
如果答案是「会」,那就是组合逻辑。比如一个与门,A和B输入1,输出马上就是1。没有延迟的概念(理想情况下)。
如果答案是「不会,要等时钟沿来了才变」,那就是时序逻辑。比如一个D触发器,数据在时钟上升沿才被锁存到输出端。
我在项目中遇到过最典型的坑:有人把组合逻辑和时序逻辑混在一起写,结果仿真对了,上板子就乱跳。为什么?因为组合逻辑的路径延迟没考虑,导致时序违规。
核心记忆点:
- 组合逻辑:用
assign或always @(*)描述,输出只依赖当前输入 - 时序逻辑:用
always @(posedge clk)描述,输出依赖时钟沿和之前的状态
3.2 模块化设计:把大问题拆成小零件
写FPGA代码和写软件不一样。软件你可以一个main函数写几千行,但硬件不行。一个模块如果超过200行,我建议你立刻拆开。
模块化设计说白了就是「分而治之」。每个模块只干一件事,接口清晰,内部实现随便改。
举个例子,一个流水灯系统,你可以拆成三个模块:
- 时钟分频模块:把50MHz的系统时钟分频成1Hz的闪烁时钟
- 计数器模块:对闪烁时钟计数,产生流水灯的位移信号
- 输出控制模块:根据计数值,点亮对应的LED
你看,每个模块都很简单。调试的时候,哪个模块出问题就单独测哪个。我曾经在一个大项目里,因为没做模块化,一个bug查了三天——最后发现是某个信号命名冲突了。从那以后,我每个模块都加前缀,比如 led_ctrl_、clk_div_,再也没出过这种低级错误。
3.3 流水灯实验:从零开始写代码
好,理论讲完了,我们直接上手。流水灯实验的目标:8个LED,从左到右依次点亮,循环往复。
3.3.1 顶层模块设计
先画个结构图,看看各个模块怎么连。我习惯用SVG画这种小框图,一目了然。
嗯,这张图很清楚了。顶层模块只负责例化子模块,连线。你想想看,如果以后要改成跑马灯,只需要改输出控制模块,其他两个完全不用动。这就是模块化的好处。
3.3.2 时钟分频模块
系统时钟一般是50MHz,周期20ns。人眼能分辨的闪烁频率大概在10Hz左右。所以我们得把50MHz分频成1Hz,这样LED每0.5秒切换一次状态。
module clk_div (
input wire clk_in, // 50MHz
input wire rst_n, // 低电平复位
output reg clk_out // 1Hz
);
// 50_000_000 / 2 = 25_000_000 次翻转一次
parameter CNT_MAX = 25_000_000 - 1;
reg [24:0] cnt;
always @(posedge clk_in or negedge rst_n) begin
if (!rst_n) begin
cnt <= 0;
clk_out <= 0;
end else if (cnt == CNT_MAX) begin
cnt <= 0;
clk_out <= ~clk_out;
end else begin
cnt <= cnt + 1;
end
end
endmodule
个人习惯:我写分频器时,参数名喜欢用全大写加下划线,比如 CNT_MAX。这样一眼就能看出是常量,不是信号。另外,复位信号我统一用 rst_n,后缀_n表示低电平有效。团队协作时,这种命名规范能省很多沟通成本。
3.3.3 计数器模块
有了1Hz的时钟,我们用它来驱动一个3位计数器。3位可以表示0~7,正好对应8个LED。
module counter (
input wire clk,
input wire rst_n,
output reg [2:0] count
);
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
count <= 0;
else
count <= count + 1;
end
endmodule
这里有个细节:count 是 reg 类型,但在 always 块里被赋值。为什么不用 wire?因为时序逻辑的输出必须用 reg 来存储状态。说白了,reg 对应硬件里的寄存器,wire 对应连线。
3.3.4 输出控制模块
最后一步,把计数器的值翻译成LED的点亮模式。最简单的流水灯:计数值为0时,点亮LED0;计数值为1时,点亮LED1……以此类推。
module led_ctrl (
input wire [2:0] count,
output reg [7:0] led
);
always @(*) begin
case (count)
0: led = 8'b0000_0001;
1: led = 8'b0000_0010;
2: led = 8'b0000_0100;
3: led = 8'b0000_1000;
4: led = 8'b0001_0000;
5: led = 8'b0010_0000;
6: led = 8'b0100_0000;
7: led = 8'b1000_0000;
default: led = 8'b0000_0001;
endcase
end
endmodule
注意!这个模块用的是 always @(*),也就是组合逻辑。组合逻辑里必须用阻塞赋值 =,不能用 <=。我曾经见过有人在这里用了非阻塞赋值,结果仿真波形完全不对,LED乱跳。记住:组合逻辑用 =,时序逻辑用 <=,这是铁律。
3.3.5 顶层模块:把零件拼起来
module top_led (
input wire clk_50m,
input wire rst_n,
output wire [7:0] led
);
wire clk_1hz;
wire [2:0] count;
// 例化时钟分频
clk_div u_clk_div (
.clk_in (clk_50m),
.rst_n (rst_n),
.clk_out(clk_1hz)
);
// 例化计数器
counter u_counter (
.clk (clk_1hz),
.rst_n (rst_n),
.count (count)
);
// 例化输出控制
led_ctrl u_led_ctrl (
.count(count),
.led (led)
);
endmodule
你看,顶层模块就是纯粹的连线。每个子模块的接口都清清楚楚,以后想换功能,比如改成「两个LED同时流水」,只需要改 led_ctrl 模块里的case表就行。
3.4 避坑指南:我踩过的那些雷
- 复位信号没处理好:我曾经有一次忘记把复位信号连到所有模块,结果上电后LED状态随机。后来我养成了习惯:每个时序模块都必须有复位,而且复位信号统一从顶层接入。
- 分频系数算错:50MHz分频到1Hz,需要计数25_000_000次。但计数器是从0开始的,所以最大值是24_999_999。如果你写成25_000_000,频率就变成0.9999Hz,肉眼看不出来,但如果你级联多个分频器,误差会累积。
- 仿真和上板不一致:仿真时时钟是理想的,但上板后时钟有抖动。如果你的分频器对时钟边沿敏感,可能会误触发。解决办法:在分频器里加一个同步器,或者用PLL生成时钟。
3.5 本章小结
这一章我们做了三件事:
| 知识点 | 核心要点 | 代码对应 |
|---|---|---|
| 组合逻辑 | 输出随输入变化,用 assign 或 always @(*) | led_ctrl 模块 |
| 时序逻辑 | 输出在时钟沿变化,用 always @(posedge clk) | clk_div、counter 模块 |
| 模块化设计 | 每个模块只干一件事,接口清晰 | top_led 顶层例化 |
说实话,流水灯虽然简单,但它包含了数字电路设计的全部核心思想:时钟、复位、分频、计数、译码。你把这个实验吃透了,后面学I2C、SPI、DDR控制器,思路都是一样的——拆成小模块,一个一个搞定。
好,这一章就到这里。代码你可以直接复制到Vivado或Quartus里跑一下,看看LED是不是真的在流水。如果有问题,回头看看避坑指南,多半能解决。
课后小练习:试着把流水灯改成「呼吸灯」效果——LED先慢慢变亮,再慢慢变暗。提示:用PWM加计数器实现。