第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_rxspi_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入门 模块化设计 端口定义 参数声明 功能描述 组合逻辑 assign 语句 always@(*) 时序逻辑 always@(posedge clk) 非阻塞赋值 <= 参数化设计 parameter 定义 实例化时重载 Testbench仿真 时钟生成 激励时序 波形记录 结果验证

这张图把本章的五个核心知识点串在了一起。你从中心往外看,每个分支都是一个独立的知识模块,但它们之间又相互关联——比如参数化设计可以用于组合逻辑模块,也可以用于时序逻辑模块。

好了,这一章的内容就到这里。记住:写Verilog不是写软件,你是在描述硬件。每写一行代码,都要想想它综合出来是什么电路。养成这个习惯,后面学起来会轻松很多。


公众号:蓝海资料掘金营,微信deep3321