Testbench编写指南:解密实战

         Testbench的编写说难也难,说易也易。之前有朋友私信留言谈到想系统学习下 Testbench,今天特意撰写这篇博客,其实说到底透过现象看本质,不同于功能模块的编写,Testbench核心任务在于验证功能模块的设计是否符合预期,所以围绕着这个目标,为了更方便理解,笔者将其简单地归纳为3个步骤:1.对被测试功能模块的顶层接口进行信号例化;2.向被测试功能模块的输入接口添加激励;3.判断被测试功能模块的输出是否满足设计预期。所以说到这里大家回头去思考下在上面3个步骤中,其中步骤1是通用性的书写规则,那么Testbench的核心设计就在步骤2编写测试激励和步骤3判断输出是否满足预期,所以按照这个思路我们再把步骤2和3的设计方法归纳总结清楚。

        我们首先来聊一聊如何编写测试激励,这也是一个非常有意思的话题,可惜的是往往在学习工作中却经常被人们忽视,就直接导致即使工作几年如果不注重总结,这方面也依然让人感到模里模糊、似懂非懂。实际项目工程当中,需要具体问题具体分析,笔者结合项目工程经验,认真总结出编写测试激励的一般性方法有:1.直接输入激励信号;2.封装测试的子程序;3.循环轮询产生激励。下面我们就对此逐一展开说明,不同的方法适用于不同的应用场景,这里也没有统一的标准答案,测试文件的编写服务于功能文件的验证。

一、直接输入激励信号

       直接输入激励信号,这是一个最简单快捷的Testbench编写方法,但是同样适用于大多数情况,通常FPGA工程师们会列举出几种典型的输入激励并和功能模块一起代入Modelsim去仿真,然后观察在这些典型的输入情况下,功能模块的输出是否符合预期。

       我们也用一个简单的功能模块举例来说明,这样更加直观明了也方便理解,比如在一个计数器练习,要求为dout初值为8'h0,当收到en1后,dout输出4个周期的8'h55;当收到en2后,dout输出8个周期的8'haa,同时en1和en2不会同时到来且间隔大于10个周期,设计目标如图1所示,表1为练习的信号列表,其中图2练习中各个信号的波形图,图3练习的代码设计。

        我们按照为已经设计好模块的功能模块编写测试文件,去验证输出是否符合预期,Testbench的编写也非常简单,只需要依次输入en1和en2两个使能信号即可,请大家打开Gvim编译器,在编译器下输入“Test”,调出Testbench的模板,我们先将功能模块例化,为了方便大家观察信号,笔者在此把练习中的flag_add和flag_sel信号也都输出并例化来了,而后再去添加en1和en2两个信号的激励即可,图4是练习的输入信号激励设计,这里就不赘述了,如图5请大家打开Vivado环境,去联合Modelsim进行练习的仿真,具体操作步骤在之前有详细介绍过,所以忘记的话可以去回顾下前面的知识,设置操作成功后即可看到如图6所示的在Modelsim下练习的仿真结果,通过这个简单的例子,也帮助大家熟悉了Gvim编辑器下的Testbench的模板,以及回顾了Vivado环境下如何联合Modelsim仿真的操作流程。

信号列表

信号名

I/O

位宽

clk

I

1

rst_n

I

1

en1

I

1

en2

I

1

dout

O

8

表1 练习信号列表

图1 练习示意图

图2 练习中各个信号的波形图

图3 练习的代码设计

图4 练习的输入信号激励设计

​​​​​图5 Vivado联合Modelsim进行练习的仿真

图6 在Modelsim下练习的仿真结果

二、封装测试的子程序

         在C语言编写过程中,比如单片机STM32程序开发,有经验的软件工程师一定会做好软件架构的规划,对于经常使用到的代码封装成函数,通过调用时,传入不同的形参达到了对代码复用的效果,同时也把相同类型的数据结构统一组织成结构体,方便不同设计层对数据进行有效的交互,很大程度上规范了程序设计。

        同样的道理,封装的思想也完全可以应用在Verilog程序设计当中,不同功能模块的信号之间进行例化,从本质上说就是一种封装思想的具体应用,类似的在测试模块也可以灵活应用task关键字,对Testbench测试文件进行代码层面上的有效封装,有效提高Testbench的可读性。在这里我们就用上一节的按键消抖功能模块来举例,编写其对应的测试文件,从而进一步说明在Testbench中应该怎么封装调用测试任务集。

          按键消抖功能模块的设计,大家如果有忘记的话,请回过头看下20个例程的内容,这里主要是说明Testbench测试模块的代码编写。因为对应有4个按键,为了模拟不同按键按下的情况,所以这里我们就用到了task关键字去封装一个任务模块:task_key_scan,在该任务模块中,我们去模拟了按键的闭合抖动和断开抖动,同时为了方便观看波形,我们把按键消抖功能模块中的20ms消抖时间改成20us。如图7所示是按键消抖功能模块的输入信号激励设计,图8所示是在Modelsim下按键消抖功能模块的仿真结果,大家可以通过查看仿真波形,发现功能模块的设计是符合预期的。

图7 按键消抖功能模块的输入信号激励设计

图8 在Modelsim下按键消抖功能模块的仿真结果

三、循环轮询产生激励

      在上面的“封装测试的子程序”中,我们封装成任务task_key_scan,然后通过调用task_key_scan成功仿真模拟了不同按键按下后,功能模块是否可以准确置位key_vld,返回对应的按键键值key_value,这样最大程度上简化了测试文件的代码数量和代码结构,这里可能细心的同学会发现,对于“按键消抖功能模块”,板子上一共只有4个按键,每次按下一个按键最多会有4种可能的情况,调用task_key_scan任务最多4次即可模拟出所有的情况,非常简单方便。

        然而更多情况下,会有很多种激励产生的可能,即使我们都封装好了task模块,想方设法简化测试文件的代码结构,也很难通过人为反复调用各类task任务去列举出所有可能的情况,使得Testbench的验证结果更有具有说服力(虽然在大部分情况下也不必代入所有可能的激励组合,选取典型的激励作为仿真文件的输入即可),下面我们来介绍“循环轮询产生激励”的方法,去循环产生所有可能的输入激励组合,从而满足一些要求苛刻的应用场景仿真需求。

      笔者想通过“串口自发自收”这个工作当中随处可见的例子,进一步说明测试文件编写方面“循环轮询产生激励”的方法。下面我们先来简单介绍下串口的底层实现,上学读书的时候,大家都会接触到TTL电平和串口等等这样的名词概念,毕业以后真正进入工作岗位成为一名嵌入式工程师,更是几乎天天都和串口打交道。串口本身也是最常用、实用的通信方式:一款产品对内可能有多块PCB板,而它们之间基本都是通过串口TTL电平通信交互数据;一款产品对外大多数都会预留RS232串口和上位机之间进行通信;一块PCB板需要和几块PCB板之间建立通信,或者一台机器需要对外和多台机器进行数据通信时候,使用RS485串口协议几乎成为大家默认的选择方式。

      这里我们从底层说起,其中串口的时序主要包括:空闲位、起始位、数据位、校验位、停止位,如图9所示是串口通信底层实现方式。

        在空闲状态时,串口的数据线会一直保持在高电平状态;当主机要发送数据时,会将数据线拉低一个波特率的时间,从而告诉从机有数据要传输了,要做好准备;起始位之后是数据位,数据位的位数由双方之前约定好即可,双方约定后才能正确地传输和解析包文。每个数据位传输时都会占用一个波特率的时间,请注意在这里是从低位到高位进行传输,比如要传输数据4'b0101,在串口传输时是先传输最低位的“1”,数据传输完成后,发送检验位:奇偶校验是一种非常简单常用的数据校验方式,又细分为奇校验和偶校验,但是一般在实际项目工程中使用的不多,所以不做详细介绍,在校验位后即为最后一位停止位,主机必须保证有停止位,即把数据线拉高一个波特率的时间。因为数据在传输线上传输,硬件上可能会有一定的干扰,每一个设备内部又有自己的时钟,很可能在通信中两台设备间出现了一些细微的不同步,停止位的到来表示整个包文传输的结束,也使得从机可以正确地识别下一轮包文数据的起始位。

        在串口传输当中,有一个非常重要的概率即波特率,串口中常用到的波特率有9600、19200、57600、115200,波特率表示每个数据在传输线上的传输速率,比如在9600的波特率下,每位数据需要传输1/9600s=104166ns的时间,所以在两个设备之间串口通信之前,都需要事先约定相同的波特率、是否有校验位、包文的长度或者包文的结束字节等等。

图9 串口通信底层实现方式

      然后我们再去编写功能模块,即串口发送模块uart_transfer,串口接收模块uart_receive以及顶层模块uart_loop,也顺便带着大家去复习下前面功能文件编写的知识。

      我们首先来看串口接收模块uart_receive,这里我们提前约定每包包文是1字节即8bit,只需要把图9所示的串口通信底层实现方式还原成verilog代码即可,我们用两个计数器cnt0和cnt1去分别计数一个波特率的时间即接收1bit时间和计数9bit时间,因为这里我们把起始位1bit和停止位1bit也算在了接收数据当中,所以cnt1需要计数10次,rxd_data在cnt1计数在1-9的时刻,使用数据拼接的老办法即可得到,但是需要大家注意一下,为了防止偏差一般串口都采用计数到一半波特率的时间去做赋值操作,在数完end_cnt1即接收完8bit数据和1bit停止位后,拉高rxd_data_vld一个时钟周期即可,uart_receive模块的信号列表如表2所示,串口接收模块uart_receive的代码设计如图10所示。

       然后同样的道理,我们再去编写串口发送模块uart_transfer模块,和uart_receive模块思路基本一致,只需要事先打包好需要发送了包文,即1bit的起始位,8bit的数据位,1bit的停止位,再按照顺序逐一送到外部引脚txd_uart即可,所以不再赘述了,大家直接看代码很好理解这两个模块的实现和功能,如果有不太明白的地方,请再对照9的示意图多思考几遍肯定可以搞明白了,没有太多绕脑的东西,uart_transfer模块的信号列表如表3所示,串口发送模块uart_transfer的代码设计如图11所示。

信号列表

信号名

I/O

位宽

clk

I

1

rst_n

I

1

rxd_uart

I

1

rxd_data

O

8

rxd_data_vld

O

1

表2 uart_receive模块信号列表

信号列表

信号名

I/O

位宽

clk

I

1

rst_n

I

1

txd_data

I

8

txd_data_vld

I

1

txd_uart

O

1

表3 uart_transfer模块信号列表

       接着我们再把uart_receive模块和uart_transfer模块通过uart_loop顶层模块例化即可,这里我们选择先收后发的方式,所以把uart_transfer模块中的txd_data和txd_data_vld信号例化为rxd_data和rxd_data_vld,图13 串口自发自收顶层模块的代码设计。

图10 串口接收模块的代码设计

  ​​​​图11 串口发送模块的代码设计

图12 串口自发自收顶层模块的代码设计

        最后我们来详细介绍串口自发自收模块的输入信号激励设计,这里就使用了“循环轮询产生激励“的方式来产生了8bit数据256种所有可能的组合,并通过测试文件的自动对比打印输出结果,节约人工观察波形的时间,极大地提高仿真测试的效率。

       为了简化代码结构,我们也把串口接收封装成一个task任务方便Testbench的灵活调用,按照串口接收的时序逻辑编写好了task_rxd_uart任务,因为在串口自发自收顶层模块的设计中,是按照先收后发的模式进行的,所以直接调用task_rxd_uart任务即可仿真模拟任何8bit串口接收的情况,在接收到串口8bit数据后,会将收到的数据再发送出去,顺其自然地再去设计好串口发送逻辑即可。

        注意到串口不论收或发,都是起始位是0,而停止位是1,空闲位是1,所以在Testbench编写中,我们用一个always块去实现串口发送逻辑,当txd_uart在下降沿的时候,即收到起始位0的时刻,整个always块内模拟实现了串口发送的过程,设置一个rs232_flag标志位,当rs232_flag被置为1时表示开始接收一包8bit包文,当rs232_flag被置为0时表示接收完成一包8bit包文。

       在Testbench的initial块内,我们遍历了0-255作为串口发送数据作为激励信号代入功能模块,当rs232_flag在下降沿的时候,即刚接收完成一包8bit包文,然后对接收到的8bit数据进行判断,如果等于发送的8bit数据,即仿真结果正确满足自发自收的测试,通过系统任务$display打印到Modelsim窗口下,如图13 是串口自发自收模块的输入信号激励设计的详细代码设计,在Vivado下添加好功能文件和测试文件,联合Modelsim仿真即可得到图14 串口自发自收模块的仿真结果,我们可以清晰地看到遍历了256种可能的8bit输入激励,Modelsim的窗口下成功打印出了仿真结果,仿真结果完全符合预期,通过上面介绍的方法整个仿真得到的结果更加具有说服力,并且省去了人工观察波形的工作,提高了整个Testbench的工作效率。

​​​​​ ​​​​​​​ ​​​​​​​ 图13 串口自发自收模块的输入信号激励设计

​​​​​​图14 串口自发自收模块的仿真结果

物联沃分享整理
物联沃-IOTWORD物联网 » Testbench编写指南:解密实战

发表评论