第4章:CPLD硬件描述语言(Verilog)入门
说实话,很多初学者一听到「硬件描述语言」这几个字,心里就发怵。我当年也一样,总觉得这玩意儿跟C语言差不多,结果写出来的代码综合出来一堆莫名其妙的电路。嗯,今天咱们就来把这层窗户纸捅破。
本章核心:掌握Verilog的模块化设计思想,理解组合逻辑与时序逻辑的区别,学会用参数化设计提升代码复用性,最后能自己写Testbench做仿真验证。
4.1 模块化设计——把大问题拆成小积木
做硬件设计,最忌讳的就是把所有逻辑塞进一个模块里。我见过一个同事,一个模块写了2000行,最后调试的时候自己都看不懂。模块化设计说白了就是「分而治之」。
一个典型的Verilog模块长这样:
module counter #(
parameter WIDTH = 8
)(
input wire clk,
input wire rst_n,
input wire en,
output reg [WIDTH-1:0] count
);
// 功能代码
endmodule
每个模块都有三要素:端口定义、参数声明、功能描述。我个人习惯把端口按方向分组——输入放前面,输出放后面,双向信号单独放。这样别人看你的代码,一眼就能知道信号流向。
小技巧:模块名用下划线分隔单词,比如 uart_rx、spi_master。端口名用缩写要谨慎,我见过有人用 d_i 表示数据输入,结果三个月后自己都忘了是什么意思。
4.2 组合逻辑——assign与always@(*)
组合逻辑,说白了就是「输入一变,输出立马跟着变」,没有时钟参与。Verilog里实现组合逻辑有两种方式:
4.2.1 用assign语句
适合简单的逻辑运算:
assign led = (btn == 1'b1) ? 1'b1 : 1'b0;
assign sum = a + b;
assign语句的左边必须是wire类型。这个我刚开始老搞混,后来记住一句话:assign = 连线,连线的类型当然是wire。
4.2.2 用always@(*)语句
适合复杂的组合逻辑:
always @(*) begin
case (sel)
2'b00: data_out = data_a;
2'b01: data_out = data_b;
2'b10: data_out = data_c;
default: data_out = 8'h00;
endcase
end
注意always@(*)里的「*」表示敏感列表自动推导。我曾经犯过一个错误:手动写敏感列表时漏掉了一个信号,结果仿真和实际电路行为不一致。从那以后,我写组合逻辑只用always@(*),再也不手动列信号了。
避坑指南:在always@(*)块里,被赋值的变量必须是reg类型。但别被「reg」这个名字骗了——它只是语法要求,综合出来依然是组合逻辑,不是寄存器。
4.3 时序逻辑——always@(posedge clk)
时序逻辑就一句话:时钟沿来了,才干活。这是CPLD设计的核心,也是初学者最容易翻车的地方。
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
count <= 8'd0;
end else if (en) begin
count <= count + 1'b1;
end
end
这里有几个关键点:
- posedge clk:时钟上升沿触发
- negedge rst_n:异步复位,低电平有效
- <= 非阻塞赋值:这是时序逻辑的标志
为什么时序逻辑里要用非阻塞赋值(<=)而不是阻塞赋值(=)?我刚开始也不理解,直到有一次写了一个移位寄存器,用=赋值,结果仿真出来数据全乱了。原因很简单:非阻塞赋值保证所有赋值在同一时钟沿同时生效,这才符合真实触发器的行为。
记住这个口诀:组合逻辑用阻塞赋值(=),时序逻辑用非阻塞赋值(<=)。混着用?我劝你别试,调试起来能让你怀疑人生。
4.4 参数化设计——写一次,用多次
做项目最怕什么?需求变了,所有代码都得重写。参数化设计就是解决这个问题的。
module shift_reg #(
parameter WIDTH = 8,
parameter DIR = "LEFT"
)(
input wire clk,
input wire rst_n,
input wire din,
output wire [WIDTH-1:0] dout
);
reg [WIDTH-1:0] shift;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
shift <= {WIDTH{1'b0}};
end else begin
if (DIR == "LEFT")
shift <= {shift[WIDTH-2:0], din};
else
shift <= {din, shift[WIDTH-1:1]};
end
end
assign dout = shift;
endmodule
实例化的时候,只需要改参数:
shift_reg #(.WIDTH(16), .DIR("RIGHT")) u_shift (
.clk(clk),
.rst_n(rst_n),
.din(data_in),
.dout(data_out)
);
你看,同一个模块,改两个参数就变成了不同的功能。我在项目中用参数化设计做过一个通用滤波器,参数一调,低通变高通,省了不少事。
4.5 Testbench仿真基础——写代码之前先想好怎么测
很多新手写完代码就直接烧进芯片,结果发现功能不对,又得回头改。我的习惯是:写代码之前,先把Testbench写好。这就像盖房子先画图纸,看着麻烦,其实最省时间。
一个简单的Testbench:
`timescale 1ns / 1ps
module tb_counter;
reg clk;
reg rst_n;
reg en;
wire [7:0] count;
// 实例化被测试模块
counter #(.WIDTH(8)) uut (
.clk(clk),
.rst_n(rst_n),
.en(en),
.count(count)
);
// 生成时钟
initial begin
clk = 0;
forever #10 clk = ~clk; // 50MHz时钟
end
// 测试激励
initial begin
rst_n = 0;
en = 0;
#100;
rst_n = 1;
#20;
en = 1;
#200;
en = 0;
#100;
$finish;
end
// 波形显示
initial begin
$dumpfile("wave.vcd");
$dumpvars(0, tb_counter);
end
endmodule
写Testbench有几个要点:
- 时钟生成:用forever语句产生连续时钟
- 激励时序:用#延迟控制信号变化时刻
- 波形记录:生成VCD文件,用GTKWave查看
我的经验:仿真时别只测正常情况。边界条件、复位行为、使能开关——这些最容易出bug的地方,一定要覆盖到。我曾经因为没测复位释放后的第一个时钟周期,结果板子跑起来数据全是乱的。
4.6 本章知识体系
下面这张图把本章的核心内容串起来了:
这张图把本章的五个核心知识点串在了一起。你从中心往外看,每个分支都是一个独立的知识模块,但它们之间又相互关联——比如参数化设计可以用于组合逻辑模块,也可以用于时序逻辑模块。
好了,这一章的内容就到这里。记住:写Verilog不是写软件,你是在描述硬件。每写一行代码,都要想想它综合出来是什么电路。养成这个习惯,后面学起来会轻松很多。
公众号:蓝海资料掘金营,微信deep3321