2. 用 PWM 实现呼吸灯,来学习仿真和层次化设计

Verilog 也可以像一般的编程语言一样,把整个系统分拆成多个模块来实现。上一节我们点了个灯,这一节我们来试试把它变成渐明渐暗的呼吸灯。在这一过程中,我们也将了解如何使用 ModelSim 仿真模块的功能,以及如何连接多个模块来组成完整的系统。

1. 设计思路

在使用单片机实现呼吸灯的时候,我们可能很快就能写下类似这样的代码:

while (1)
{
    for(i = 0; i < 200; i++)
    {
        pwm_set(LED_PIN, i);
        delay_ms(5);
    }
    for(i = 200; i > 0; i--)
    {
        pwm_set(LED_PIN, i);
        delay_ms(5);
    }
} // (这是伪代码)

但是,在空的 FPGA 中,暂时还没有部件来帮你执行类似 pwm_set() 的函数,甚至根本没有部件来解析这些指令。我们如果要通过 FPGA 实现呼吸灯,最基础的方法是写硬件描述语言描述出专门(且只能)实现这些功能的数字电路。

考虑呼吸灯的效果,我们可以先描述一个 PWM 模块,它能接受一组输入来设置占空比,然后输出 PWM 信号控制灯的明暗,这样就以最朴素的形式达成了类似 pwm_set() 的功能,该模块的端口类似下图。

有了 PWM 模块,就可以控制灯的明暗了。但是,这还没有呼吸,我们还需要实现类似下边这段编程语言的功能,每隔一段很短的时间改变给 PWM 模块传入的信号,让 PWM 占空比变化,才能让灯呼吸起来。

while (1)
{
    for(i = 0; i < 200; i++)
    {
        delay_ms(5);
    }
    for(i = 200; i > 0; i--)
    {
        delay_ms(5);
    }
}

这个功能可以通过计数器来实现,计数器可以在每接受一次上升沿(或下降沿)时递增或者递减它的计数值,然后通过自己的输出端传给 PWM 模块。

由 YADAN 文档中对时钟电路的介绍 可知,YADAN Board 上有一个 24MHz 的时钟连接到了 FPGA 的 P34 引脚。假如我们想让计数器每隔 5ms 改变一次数值,可以描述一个分频器,把 24MHz 的时钟分频成 200Hz 再作为计数器的输入时钟。

即,我们可以通过下图所示的整体结构来实现呼吸灯的效果。

图中,CLK_24MHz 是开发板上的 24MHz 时钟连接的引脚,它经由 120 千倍的分频器 divider_120k 分频成 200Hz 之后连接到 counter_200,让其产生计数值来设置 PWM 占空比。同时,CLK_24MHz 也直接连接到了 PWM_module,让其产生最终的 PWM 信号。

为了增强学习效果,我们还给 counter_200 增加了一个引脚连接至 K_51 按键,按键被按下后,计数器会暂停计数,让呼吸灯暂时窒息。

可以在 这个 GitHub 仓库 先获取本实验的项目文件和代码,烧录到开发板上体验一下,再阅读后续内容。

2. 使用 ModelSim 仿真模块的功能

在用编程语言写代码的时候,我们如果不确定运行效果,可以用调试工具、甚至也可以直接用 print() 来观察执行过程。但是,硬件描述语言和编程语言是完全不同的,它描述的是电路,并不能直接“单步执行”或“打印”。我们需要设定输入信号,使用仿真工具仿真出输出信号来检查用 HDL 编写的模块的功能是否与预期相符。

一款比较常用的 HDL 仿真工具叫 ModelSim,它的 官网 上有它的介绍。如果你能够使用 ModelSim 的话,在 TD 工具如下图位置点开软件手册,里边的附录部分有一节介绍了如何将 Anlogic 的器件添加进 ModelSim,请先根据手册内的提示完成配置。

HDL 代码的测试文件一般被称为 test bench,习惯上,一般在待测模块的名字后边加上 _tb 表示是该模块的测试文件。例如,本流水灯实验的项目目录里边有这些 .v 文件:

2.PWM_example
    ├── src
    │   ├── PWM_example_top.v
    │   ├── PWM_module.v
    │   ├── PWM_module_tb.v
    │   ├── counter_200.v
    │   ├── counter_200_tb.v
    │   ├── divider_120k.v
    │   └── divider_120k_tb.v
    └── ……

其中,counter_200_tb.v 就表示它是 counter_200.v 的测试文件,我们可以以这两个文件为例来体验如何使用 ModelSim 来仿真刚写好的 HDL 代码。假如,我们刚洋洋洒洒写好了 counter_200.v,但是不确定它递增计数时到底计到 199 就切换计数方向,还是到 200 或者 201 时才切换,就可以写这样一个测试文件:

`timescale 1ms/1us

module counter_200_tb ();
    
    // [1] 输入
    reg clk_in;
    reg pause_n;
    
    // [2] 输出
    wire [7: 0] duty_cycle;
    
    // [3] 例化待测模块
    counter_200 uut_counter(
    .clk_in (clk_in),
    .pause_n (pause_n),
    .duty_cycle (duty_cycle)
    );
    
    // [4] 描述输入信号
    initial begin
        clk_in = 0;
        
        pause_n = 1;
        repeat(1000)
            #2.5
            clk_in = ~clk_in;
        
        pause_n = 0;
        repeat(1000)
            #2.5
            clk_in = ~clk_in;
    end
    
endmodule

代码中,第 [1] ~ [3] 部分描述了输入输出端口、例化了待测模块,第 [4] 部分描述了输入信号。代码中描述的输入信号是 clk_in 端口初值为 0,pause_n 端口初值为 1,重复 1000 遍每隔 2.5ms 翻转 clk_in 的值;接下来,将 pause_n的值设为 0,再重复 1000 遍每隔 2.5ms 翻转 clk_in 的值。

在 ModelSim 中编译并运行仿真,即可看到仿真的模块输出信号。

如果一组信号表示的是数值的话,右键它,在 Radix 中可以选择进制或者格式,ModelSim 会将信号解析为你选择的格式显示在图中。比如最初设计 [7: 0] duty_cycle 时,是用无符号 8 位二进制整数来表示占空比的设置值,就可以选择 Unsigned,然后在右边图中就能直接看到 8 根信号线对应的十进制数字了。

翻看仿真出来的波形图,即可检查 counter_200.v 的功能是否与预想的一致。当然,test bench 也支持写代码来自动检查,感兴趣的读者可以上网搜索了解更多。

3. 例化模块与连接实例

如果大家曾经使用过面向对象编程语言,会听说过类 (class) 和对象 (object) 的概念,类可被视为是模板,对象可被视为是实例,即,可以根据类来实例化 (instantiate) 出对象。在 Verilog 中,如果我们编写好一个模块,也可以将其 instantiate 成实例再组成成整个系统。不过,虽然英文都是 instantiate,但是有关 HDL 的中文资料中似乎更经常把 instantiate 称为 “例化”。

以本实验为例,在第 1 节中,我们展示了这样一张整体的结构图:

其中,divider_120k、counter_200、PWM_module 三个子模块分别由 divider_120k.v、counter_200.v、PWM_module.v 三个文件中的代码描述。除了这三个模块和 test bench 文件之外,可以发现还有一个 PWM_example_top.v 文件,它里边写了如下代码:

module PWM_example_top(input clk_in,    // 24MHz
                       input pause_n,   // 按住暂停
                       output pwm_out); // 点灯引脚
    
    // 功能:把三个子模块连起来,实现亮暗变化周期为 2s 的呼吸灯
    
    // [1] 创建 wire
    wire clk_200;
    wire [7: 0] duty_cycle;
    
    // [2] 例化各个模块
    divider_120k divider_120k_0 (
    .clk_in(clk_in),
    .clk_out(clk_200)
    );
    
    counter_200 counter_200_0(
    .clk_in (clk_200),
    .pause_n (pause_n),
    .duty_cycle(duty_cycle)
    );
    
    PWM_module #(8) PWM_module_0(   // #(8) 是传入 parameter,详细内容可见 PWM_module.v
    .clk_in (clk_in),
    .en (1'b1),                     // 给启动端口固定为高电平
    .duty_cycle (duty_cycle),
    .pwm_out (pwm_out)
    );
    
endmodule

代码中,第 [1] 部分创建了两根 wire,类似连线,以备子模块使用,第 [2] 部分分别例化了三个子模块,分别命名为了 divider_120k_0、counter_200_0、PWM_module_0(尾部加个 _0 是为了把实例和原模块的名字区分开),并描述了各个实例之间的连接关系。可以发现,这份代码和我们最初设计的那张整体结构图描述的内容是几乎一样的,从顶层描述了整个呼吸灯所需的部件,所以,这个文件可被称为 顶层文件,其中的 PWM_example_top 模块可被称为 顶层模块。TD 工具等 FPGA 开发工具中通常都会提供将某个文件 Set as top 的选项,即是为了告诉综合器哪个模块才是顶层模块。

知道了如何层次化设计以及如何仿真,就已经掌握 FPGA 开发工具的基础使用方法了。想了解这个实验的代码的更多细节,请访问 https://github.com/CSY-tvgo/Learn-Verilog-with-YADAN-Board/tree/main/2.PWM_example 阅读完整代码。

 


评论:

在 YADAN Board 上入门 Verilog

作者:VeriMake   共5讲

本系列材料共有 5 个主线章节 与 若干个额外示例,主线章节可帮助你基本了解 Verilog 与 TangDynasty 开发工具的使用方法,额外示例可在你需要开发更丰富的应用时提供参考。