首页->【中科大FPGA交流小组】

9 1

我们分析一下功能。串口调试助手发数据给FPGA,站在FPGA的角度来看,就是CH340通过控制RX信号,让RX信号根据串口时序变化,从而告知FPGA数据信号。那么FPGA工程必须有一个接口信号,命名为rx_uart。
FPGA要控制8个LED灯的亮灭,那就要8个信号,或者一个8比特的信号,命令为led。
综上所述,我们这个工程需要4个信号,时钟clk,复位rst_n,串口输入信号rx_uart和输出控制LED灯的8位信号led。
我们先分析要实现的功能,led信号控制了8个LED灯的亮灭,而具体哪些灯亮哪些灯灭,是取决于串口过来的数据。那串口过来的数据,是如何告知FPGA,并与led对应起来呢?我们可以看一下串口过来的时序。


上面波形是CH340控制的信号rx_uart。如果CH340要发送一个8位数据data,它首先会将信号rx_uart变0并持续一段时间(起启位),然后发送data[0]并持续一段时间,然后发送data[1]并持续一段时间,以此类推,发送data[7]并持续一段时间,最后CH340将rx_uart为1并持续一段时间(结束位)。这样CH340就完成了数据的发送。
例如,CH340要发送的数据data=8’h00110001,则rx_uart的波形如下。

再考虑一下时间信息。由于波特率是9600,那么每位持续的时间是1s/9600=104166ns。将时间信息补上波形。

本开发板的晶振时钟是50Mz,104166ns/20ns约等于5208个时钟周期。也就是说上面波形中,每个比特的持续时间约等于5208个时钟周期。需要注意的是,5208只是一个估计的大概数字,实际情况会有偏差。同时,我们还有计数这是第几个比特,用于让我们判断是开始位、数据位和停止位等。


可以看出,我们需要2个计数器,1个计数器用于计算1比特的位宽长度:5208个时钟周期,命名为cnt0;另一个用于计算有多少个比特,命名为cnt1。
很明显cnt0每次都是数5208个,但cnt0的加1条件需要仔细分析。我们很清楚cnt0的加1区域是下面的灰度地方。

目前没有任何信号可以区分出此区域。参考至简设计法案例2的方法,设计一个信号flag_add,当其为1表示上述灰度区域,即cnt0的加1区域。

有了flag_add,我们就很明确,cnt0的加1条件是flag_add==1,数到5208下就结束。为此,可以写出cnt0的代码。

  1
  2
  3
  4
  5
  6
  7
  8
  9
  10
  11
  12
  13
  14
  
  always @(posedge clk or negedge  rst_n)begin
       if(!rst_n)begin
           cnt0 <= 0;
       end
       else if(add_cnt0)begin
           if(end_cnt0)
              cnt0 <= 0;
           else
              cnt0 <= cnt0 + 1;
       end
  end
   
  assign add_cnt0 = flag_add==1 ;
  assign end_cnt0 = add_cnt0 &&  cnt0 == 5208-1 ;
  




接下来思考cnt1。Cnt1用于表示数到第几比特,每间隔一个比特时间,cnt1就会加1,也就是说,每当end_cnt0时,cnt1就会加1。所以cnt1的加1条件是:end_cnt0。而cnt1一共要数9个。故可以写出cnt1的代码。

  1
  2
  3
  4
  5
  6
  7
  8
  9
  10
  11
  12
  13
  14
  
  always @(posedge clk or negedge  rst_n)begin 
       if(!rst_n)begin
           cnt1 <= 0;
       end
       else if(add_cnt1)begin
           if(end_cnt1)
              cnt1 <= 0;
           else
              cnt1 <= cnt1 + 1;
       end
  end
   
  assign add_cnt1 = end_cnt0;
  assign end_cnt1 = add_cnt1 &&  cnt1 == 9-1 ;
  




我们增加了辅导信号flag_add,现在我们来思考如何设计这个flag_add。Flag_add有2个变化点:变0和变1。Flag_add什么时候会变1呢?我们从功能上来理解一下。上电后,PC如果没有发送任何数据,rx_uart是一直保持为1。此时cnt0和cnt1也无须计数,也就是flag_add也一直保持为0。当PC要发送数据了,rx_uart就按串口时序变化,首先会发送一个开始位,即rx_uart由1变成0。FPGA看到rx_uart变1变成0后,就明白PC有数据要过来了,并且现在是开始位,此时cnt0和cnt1要计数了,也就是flag_add要变成1了。
从功能上理解,很容易就知道当原来flag_add为0,此时收到rx_uart由1变0(下降沿)时,flag_add就变成1。关键的是,我们如何知道rx_uart变1变成0呢?
有读者会用下面方式来检测rx_uart的下降沿。

  1
  2
  3
  4
  5
  6
  7
  8
  
  always   @(posedge clk or negedge rst_n)begin
       if(rst_n==1'b0)begin
           flag_add <= 1 ;
       end
       else if(end_cnt1)begin
           flag_add <= 0 ;
       end
  end
  



将rx_uart放到ALWAYS语句的敏感列表,而敏感列表里刚好有一个negedge检测下降沿的语句,这样就实现了rx_uart的下降沿检测。不得不说,这是一个聪明的做法。从代码层面来说,这个功能貌似是可以实现的。并且如果是实验工程,好像也能得到正确的结果。然而,在真正的工程实践中,这是不可取的做法。
1.       读者有没有想过,为什么我们数字电路里都是二进制0和1?也就是低电平为0,高电平为1。有读者觉得这多浪费啊,为啥不搞多几个电平,例如可以低电平为0,1V为1,2V为2,3V为3。这样一根线就可以表示4种状态,也是四进制,效率不是提高了一倍吗?对于一根线来说是这样的,但对于一个系统来说则完成不同。系统要求器件越简单越好,考虑得越少越好,这样才能方便集成和扩展,才能无限地复制。1个四进制的器件,情愿选择2个二进制的器件。另外,越简单的器件,故障的可能性就越低。越简单的器件,越容易优化和发展。例如,二进制器件,我们不断地优化其体积和电压范围,则能不断地发展。则四进制器件,则会收到各个方面的制约,是会受到瓶颈的。这也是为什么数字电路比模块电路快速发展的原因。
2.       至简设计法也是同样道理。别看我们的规则简单,但就是制定了这么简单的规则,我们的设计角度就从波形设计转到了功能设计。我们的头脑中,不再去想复杂的波形,不再去想着对齐波形时序。我们更关注的是功能,例如计数10下,我们就直接用add_cnt && cnt==10-1表示。Dout信号在数到10个时就变高,我们就会写出下面代码:
If(add_cnt && cnt==10-1)
   dout <= 1;
这就是功能设计。
当然,有读者会疑问,这样不用考虑波形,真能保证波形是正确的吗?其实,这方面已经有明德扬的规范来保证,只要遵守了明德扬的规范,一定能保证正确性。读者可以试着挑些代码,从波形上验证正确性。
有工程师工作了10年,但却只有2年的工作经验。即使工作10年,也只是旧经验的简单重复,丝毫没有层次的上升。正常的上升道路应该是:一年波形设计(熟练掌握各种接口时序设计),2年功能设计(任何算法和功能,都能简单高效地设计出来),5年FPGA架构设计(能设计出高效的FPGA内部架构,精通模块划分),7年项目设计(ARM、DSP和FPGA之间的功能划分),10年产品设计(客户需求的落地,转化到项目设计)。

3.       时钟和复位,关系到整个FPGA工程的稳定。一般要求时钟精确稳定,抖动要小。FPGA里所有的触发器,都在时钟的节拍下,统一进行翻转。由于时钟周期是固定的,工程师在设计时会考虑电路延时,以便在时钟下次上升沿前计算完毕。只要所有的电路延时,都能够在在下次时钟沿前处理完毕,统一翻转,那么整个系统都是稳定的。但如果一个系统中,时钟过多,拥有不同的时钟周期,那么每个电路延时要求就不尽相同,工程师要考虑的也就越多,系统也就越不稳定。所以,一个工程,时钟越少越好,越简单就越稳定。复位也是同样的道理。所以,我们在设计时,切忌将信号接到敏感列表那里,接到那里的信号,就会被系统认为是时钟或者复位。

检查rx_uart的下降沿,就要用到FPGA里的边沿检测技术。所谓的边沿检测,就是检测输入信号,或者FPGA内部逻辑信号的跳变,即上升沿或者下降沿的检测。这在FPGA电路设计中相当的广泛。其电路图如下。



中间信号,trigger连到触发器的信号输入端D,触发器的输出器连的是tri_ff0。将trigger取反,与tri_ff0相与,就得到信号neg_edge,如果neg_edge=1就表示检测到trigger的下降沿。将tri_ff0取反,与trigger相与,就得到信号pos_edge,如果pos_edge=1,就表示检测到trigger的上升沿。
我们来讲解这个原理,画出信号的波形图。

Tri_ff0是触发器的输出,因此tri_ff0的信号与trigger信号相似,只是相差一个时钟周期。我们也可以这样理解:每个时钟上升沿看到的tri_ff0的值,其实就是triffer信号上一个时钟看到的值,也就是tri_ff0是trigger之前的值。
然后我们在看第3时钟上升沿,此时trigger值为0,而tri_ff0的值为1,即当前trigger的值为0,之前的值为1,这就是下降沿,此时neg_edge为1。当看到neg_edge为1,就表示检测到trigger的下降沿了。
同样道理,在第7个时钟上升沿,看到trigger值为1,而之前值为0,pos_edge为1,表示检测到trigger的上升沿。
Verilog实现边沿检测电路的代码。

  1
  2
  3
  4
  5
  6
  7
  8
  9
  10
  11
  12
  13
  14
  15
   
  
   
  always   @(posedge clk or negedge rst_n)begin
       if(rst_n==1'b0)begin
           tri_ff0 <= 0;
       end
       else begin
           tri_ff0 <= trigger ;
       end
  end
   
  assign neg_edge = trigger==0 && tri_ff0==1;
  assign pos_edge = trigger==1 &&  tri_ff0==0;
   
  



在讨论边沿检测的波形中,我们把trigger当成理想的同步信号,也就是trigger是满足D触发器的建立和保持时间的,这在同步系统中不是问题。但如果trigger不是理想的同步信号,例如外部按键信号,例如本工程的rx_uart信号。这些信号什么时候变化,完全是随机的。很有可能,在时钟上升沿变化,从而不满足触发器的建立时间和保持时间要求,从而出现亚稳态,导致系统崩溃。详细的原因,可以看D触发器中,亚稳态一节的内容。根据这一节内容的结论,我们需要对进来的信号打两拍(用两个触发器寄存一下),再来使用。

假设输入的信号trigger不是同步信号,那么要将该信号用2个触发器进行寄存,得到tri_ff0和tri_ff1。需要特别注意的是,tri_ff0绝对不要拿来当条件使用,只能使用tri_ff1。我们还需要检测边沿,根据前面所说,再用寄存器寄存,得到tri_ff2。根据tri_ff1和tri_ff2,我们就可以得到边沿检测。当tri_ff1==1且tri_ff2==0时,上升沿的pos_edge有效;当tri_ff1==0且tri_ff2==1时,下降沿的neg_edge有效。

  1
  2
  3
  4
  5
  6
  7
  8
  9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  1
  
   
  always   @(posedge clk or negedge rst_n)begin
       if(rst_n==1'b0)begin
           tri_ff0 <= 0;
           tri_ff1 <= 0;
           tri_ff2 <= 0;
       end
       else begin
           tri_ff0 <= trigger ;
           tri_ff1 <= tri_ff0 ;
           tri_ff2 <= tri_ff1 ;
       end
  end
   
  assign neg_edge = tri_ff1==0 &&  tri_ff2==1;
  assign  pos_edge = tri_ff1==1 && tri_ff2==0;
  



我们总结一下。如果通过打两拍的方式,实现了信号的同步化。我们通过打一拍的方式,实现边沿检测电路。这两者不是一定同时出现的。如果进来的信号是异步信号,那就必须先同步化,然后再做检测。如果进来的信号本身就是同步信号,那就没有必要做同步化了,直接做边沿检测即可。

回到本工程的设计,我们需要检测rx_uart的下降沿,从而让flag_add变高。同时,我们注意到rx_uart是异步信号(PC什么时候发送数据就是随机的)。所以需要将rx_uart先同步化,再做下降沿检测。所以先设计如下代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
  10
  11
  12
  
  always   @(posedge clk or negedge rst_n)begin
       if(rst_n==1'b0)begin
           rx_uart_ff0 <= 0 ;
           rx_uart_ff1 <= 0 ;
           rx_uart_ff2 <= 0 ;
       end
       else begin
           rx_uart_ff0 <= rx_uart ;
           rx_uart_ff1 <= rx_uart_ff0 ;
           rx_uart_ff2 <= rx_uart_ff1 ;
       end
  end
  




这样,flag_add变1的条件就变成:rx_uart_ff1==0 &&rx_uart_ff2==1。
Flag_add变0的条件,可以完成收完9比特数据就变0,不用再计数了。所以变0条件:end_cnt1。
综上所述,可以写出flag_add的代码。

  1
  2
  3
  4
  5
  6
  7
  8
  9
  10
  11
  
  always   @(posedge clk or negedge rst_n)begin
       if(rst_n==1'b0)begin
           flag_add <= 0 ;
       end
       else if(rx_uart_ff1==0 && rx_uart_ff2==1)begin
           flag_add <= 1 ;
       end
       else if(end_cnt1)begin
           flag_add <= 0 ;
       end
  end
  




设计下data信号,该信号的值来自于图中第2~第9比特的值。第2比特的值赋给data[0],第3比特的值赋给data[1],以此类推,第9比特的值赋给data[7]。

由于每一个比特都持续5208个时钟周期,我们必须选定一个时刻,将值赋给data。

首先,不能在end_cnt0的时候赋值,如上图的点。因为我们这里的5208个时钟周期是理想、估算的数值,实际上是非常有可能有偏差的。如果我们在end_cnt0的时候取值,就有可能采错。
最保险的做法是在中间点取值。这样,即使有比较多的偏差,都不会影响到采样的正确性。

综上所述,我们在cnt0数到一半时采到当前rx_uart的值赋给dout,其中第2比特赋给dout[0],第3比特赋给dout[1],以此类推,第9比特赋给dout[7]。
进一步用信号表示,可翻译成:数到add_cnt0 &&cnt0==5208/2 -1时,如果cnt1==1,则将rx_uart_ff1赋给dout[0]。如果cnt1==2,则将rx_uart_ff1赋给dout[1],以此类推,如果cnt1==8,将rx_uart_ff1赋给dout[7]。
那么直接翻译成代码。

  1
  2
  3
  4
  5
  6
  7
  8
  9
  10
  11
  12
  13
  14
  15
  16
  17
  18
  19
  20
  21
  22
  23
  
  always   @(posedge clk or negedge rst_n)begin
       if(rst_n==1'b0)begin
           dout <= 0 ;
       end
       else if(add_cnt0 && cnt0==5208/2-1)begin
           if(cnt1==1)
              dout[0] <= rx_uart_ff1 ;
           else if(cnt1==2)
              dout[1] <= rx_uart_ff1 ;
           else if(cnt1==3)
              dout[2] <= rx_uart_ff1 ;
           else if(cnt1==4)
              dout[3] <= rx_uart_ff1 ;
           else if(cnt1==5)
              dout[4] <= rx_uart_ff1 ;
           else if(cnt1==6)
              dout[5] <= rx_uart_ff1 ;
           else if(cnt1==7)
               dout[6] <= rx_uart_ff1 ;
           else 
              dout[7] <= rx_uart_ff1 ;
       end
  end
  




上面代码可优化,简写成如下:

  1
  2
  3
  4
  5
  6
  7
  8
  
  always   @(posedge clk or negedge rst_n)begin
       if(rst_n==1'b0)begin
           dout <= 0 ;
       end
       else if(add_cnt0 && cnt0==5208/2-1 && cnt1>=1  && cnt1<9)begin
           dout[cnt1-1] <= rx_uart_ff1 ;
       end
  end
  




通常我们设计时,首先是想到实现功能,所以会先写出前面代码。在功能实现的前提下,再考虑有没有优化空间,从而写出后面代码。好代码都是一步步优化出来的。
注意,上面代码,我们采集的是rx_uart_ff1而不是rx_uart信号。这是因为rx_uart是异步信号,我们只能用同步化后的信号,否则会引起亚稳态。所以只能是rx_uart_ff1。

我们还要设计一个信号data_vld,用来表示数据有效性。每当收集完8位数据后,就可以产生一个时钟的高电平。可以用end_cnt1表示。所以代码为:

  1
  2
  3
  4
  5
  6
  7
  8
  
  always   @(posedge clk or negedge rst_n)begin
       if(rst_n==1'b0)begin
           dout_vld <= 0 ;
       end
       else begin
           dout_vld <= end_cnt1 ;
       end
  end
  



                                                           
至此,主体程序已经完成。接下来是将module补充完整。
将module的名称定义为uart。并且我们已经知道该模块有四个信号:clk、rst_n、rx_uart和dout。为此,代码如下:

  1
  2
  3
  4
  5
  6
  
  module uart(
       clk       ,
       rst_n     ,
       rx_uart   ,
       dout
       );
  




其中clk、rst_n和rx_uart是输入信号,dout是输出信号,其中clk、rst_n、rx_uart的值是0或者1,一根线即可,dout为8位位宽的,根据这些信息,我们补充输入输出端口定义。代码如下:

  1
  2
  3
  4
  
  input               clk    ;
  input               rst_n  ;
  input               rx_uart;
  output [7:0]        dout    ;
  




接下来定义信号类型。
cnt0是用always产生的信号,因此类型为reg。cnt0计数的最大值为5208,需要用13根线表示,即位宽是13位。因此代码如下:

  1
  
  reg    [12:0]   cnt0     ;
  



add_cnt0和end_cnt0都是用assign方式设计的,因此类型为wire。并且其值是0或者1,1个线表示即可。因此代码如下:

  1
  2
  
  wire           add_cnt0 ;
  wire           end_cnt0 ;
  




cnt1是用always产生的信号,因此类型为reg。cnt1计数的最大值为9,需要用4根线表示,即位宽是4位。因此代码如下:

  1
  
  reg          [3:0]     cnt1;
  



add_cnt1和end_cnt1都是用assign方式设计的,因此类型为wire。并且其值是0或者1,1根线表示即可。因此代码如下:

  1
  2
  
  wire                    add_cnt1;
  wire                    end_cnt1;
  




flag_add是用always方式设计的,因此类型为reg。并且其值是0或者1,1根线表示即可。因此代码如下:

  1
  
  reg                    flag_add;
  



rx_uart_ff0、rx_uart_ff1和rx_uart_ff2是用always方式设计的,因此类型为reg。并且其值是0或1,需要1根线表示即可。因此代码如下:

  1
  2
  3
  
  reg                    rx_uart_ff0;
  reg                    rx_uart_ff1;
  reg                    rx_uart_ff2;
  



dout_vld是用always方式设计的,因此类型为reg。并且其值是0或1,用一根线表示即可,因此代码如下:

  1
  
  reg                    dout_vld;
  



至此,整个代码的设计工作已经完成。下一步是新建工程和上板查看现象。
四、综合工程和上板
新建工程
1.首先在d盘中创建名为“uart”的工程文件夹,将写的代码命名为“uart.v”,顶层模块名为“uart”。
  
2.     然后打开Quartus Ⅱ,点击File下拉列表中的New Project Wzard...新建工程选项。

3.在出现的界面中直接点击最下方的“Next”。


4.之后出现的是工程文件夹、工程名、顶层模块名设置界面。按照之前的命名进行填写,第一栏选择工程文件夹“uart”,第二栏选择工程文件“uart.v”,最后一栏选择顶层模块名“uart”,然后点击”Next”,再出现的界面选择empty project。
  
5.之后是文件添加界面。在上方一栏中添加之前写的uart.v文件,点击右侧的“Add”按钮,之后文件还会出现在大方框中,之后点击“Next”。

6.     器件型号选择界面。在“Device family”处选择Cyclone ⅣE,在“Available devices”处选择EP4CE15F23C8,然后点击“Next”。


7.     EDA工具界面。该页面用默认的就行,直接点击最下方“Next”。

8.       之后出现的界面是我们前面的设置的总结,确认没有错误后点击“Finish”。

综合
1.新建工程步骤完成后,就会出现以下界面。在“Project Navigator”下选中要编译的文件,点击上方工具栏中“StartCompilation”编译按钮(蓝色三角形)。

2.编译成功后会出现以下界面,点击“OK”。

配置管脚
1.点击管脚配置按钮“Pin Planner”,进入管脚配置界面。

2.下图是我们要用的原理图,其中箭头所指就是我们要配置的11个管脚,分别是clk、rst、LED1、LED2、LED3、LED4、LED5、LED6、LED7、LED8和RXD,对应的管脚分别是G1、AB12、AA4、AB4、AA5、AB8、AA10、AB13、AB14、AB16、D6。


  
3.在图中下方“Location”下面的空格中双击,填上对应的管脚号,回车即可。


布局布线
管脚配置完成后,在进行一次编译。
连接开发板
图中,下载器接入电脑USB接口,电源接入电源,uart线连接电脑USB,然后摁下电源开关,看到开发板灯亮。

上板
1.双击Tasks一栏中”Program Device”。

2.会出现如下界面,点击add file添加.sof文件,在右侧点击“Start”,会在上方的“Progress”处显示进度。

3.进度条中提示成功后,即可在显示器上观察到相应的现象。
串口调试
1,安装串口调试工具。

2,开发板连接完成,打开电源后,在设备管理器中查看串口名。

3,打开串口调试助手,在串口处选择之前查看的串口名,波特率、校验位、数据位、停止位根据之前给出的数据进行填写,发送和接收都选择十六进制。

4,上板成功之后,在发送数据栏输入相应的数据(将8个LED灯对应的8位二进制数转化为十六进制),然后发送,即可在开发板上看到相应的现象。


楼主可见

  1. wxcc 1#

    刚好需要这个