美文网首页
Arduino学习:FPGA HDL基础

Arduino学习:FPGA HDL基础

作者: 于鹏飞_d9e3 | 来源:发表于2022-03-11 23:50 被阅读0次

此内容在Arduino.cc官网直接翻译学习得来,买了一个Arduino Vidor4000的带FPGA的开发板子,本来想深入学学,奈何没有什么时间和精力,先记录下来以后再说吧。

以下是译文,翻译依赖谷歌,同时进行校对。水平受本人英语水平和知识量限制。

FPGA其实是一种创建定制硬件的相对较老的方法,它可消除与芯片代工厂一些相关的成本。 不幸的是,芯片设计的复杂性太高,大多数人依旧倾向于使用现成的芯片,并且接受其局限性,而不是接收挑战,获得一个优化的高效的符合自己需求的硬件。

和软件相似,您可以选择从许多库文件开始,FPGA也有称为IP核的“库”,但它们通常很昂贵,且缺乏标准化的“即插即用”接口,在将系统中的所有内容集成在一起时会引起麻烦。

Arduino试图在其产品线中引入FPGA的目的,是利用可编程硬件的灵活性,特别是为微控制器提供可扩展的外围设备,从而消除大多数复杂性。 当然,要实现这一点,有必要施加一些限制,并定义一种标准的模块互连方式,以使其能够自动化的完成。

要做到这点,第一步需要定义一组标准的接口。这些接口必须严格响应给定的一组规则。但在深入研究之前,重要的是要定义我们可能需要的接口类型。因为与微控制器对接,需要定义的第一个端口是将处理器与外围设备互连的总线。 这样的总线至少应存在于主信号和从信号中,在这些信号中,信号相同但方向相反。

下一个接口很重要,但无法标准化,它是连接到外部世界的输入/输出信号。此处无法定义标准,因为每个块都将提供自己的信号集,但是我们可以将一组信号捆绑在一起,称为管道。

最后的第三类接口很有用,它可以承载流数据。在这种情况下,希望传输连续的数据流,但如果接收块无法处理它,还希望能够暂停该流,因此除了数据之外,我们还需要某种类似于UART的流控信号。

由于我们还希望对可读性进行一些标准化,因此我们还设置一些编码约定。当然,这里有很多不同的约定,它们都以空格/制表符等符号等为准则,因此我们选择了我们喜欢的约定...在谈论约定时,最终还会谈论语言。Arduino更喜欢(System)Verilog,而不是VHDL,并且大多数IP块都使用它进行编码。选择的原因是Verilog通常更类似于C,并且还允许使用非常好的构造来促进创建参数块。

关于编码约定
- 在每个已声明实体的前面使用前缀,以便标识其类型,变量名完全为大写,多个单词之间用下划线分隔。 特别是:

  w Wire,用于所有组合的信号,例如wDATA。 通常用wire指令定义

  r Reg,对于所有时序信号,例如rSHIFTER。 通常用reg指令定义

  i Input,用于模块声明中的所有输入信号,例如iCLK。 通常用input指令定义

  o Output, 用于模块声明中的所有输出信号,例如oREAD。 通常用output指令定义

  b Biirectional, 双向,用于模块声明中的所有出入信号,例如bSDA。 通常用inout指令定义

  p Parameter, 参数,用于可用于参数化块的所有参数,例如pCHANNELS。 通常用param指令定义

  c Constant, 常量,对于所有常量或为派生值且不能直接用于参数化块。 例如cCHANNEL_BITS。 通常用localparam指令定义

  e Enumerated 枚举,用于一个或多个信号或寄存器使用的所有可能的常数值。 例如,状态机状态可以定义为eSTATE。 通常用enum指令定义

代码规则:

- 更倾向使用空格而不是制表符。 原因是无论标签大小如何,空格的代码始终看起来不错。
- 缩进的设置为两个空格。
- 条件语句块即使在其中只有一个语句,也应始终具有begin / end构造,并且begin / end应该与if / else处于相同的代码行上。
- 属于同一组的信号应共享一个公共的前缀。

接口原型

轻型总线

总线用于互连外围设备。 按照惯例,数据总线为32位,而地址总线为可变宽度,具体取决于要公开的寄存器数量。 总线需要以下信号集:

  信号 | 方向 | 宽度 | 描述

  ADDRESS |  主O 从I | 长度var | 寄存器地址,宽度可决定

  READ | 主O 从I | 长度1 | 发出读的脉冲信号

  READ_DATA | 主I 从O | 长度32 | 数据获取并被读取

  WRITE | 主O 从I | 长度1 | 发出写的脉冲信号

  WRITE_DATA | 主O 从I | 长度32 | 数据写入给定的地址上

  BYTE_ENABLE | 主O 从I | 长度4 | 可选信号,用于标记将实际写入32位字的哪些字节

  WAIT_REQUEST | 主I 从O | 长度1 | 标记外围设备忙的可选信号。 仅当未声明此信号时,读和写选通才被视为有效。

按照惯例,在写周期中,ADDRESS和WRITE_DATA在WRITE选通脉冲的同一时钟周期内被锁存。相反,在读周期中,外围设备在紧跟READ选通脉冲的那个周期提供READ_DATA,这也同时表示正在读取ADDRESS。

流水线总线

一种总线,用于互连复杂的块,这种总线可以一次处理多个命令,并可在不同的时间去响应请求。 该总线通过添加以下信号来扩展轻型总线:

此行为也称为1时钟读取等待时间,基本上意味着尽管外围设备仍然可以使用可选的WAIT_REQUEST信号来具有可变数量的时钟来响应READ或WRITE操作,但这将锁定主机,阻止主机执行其他操作。 从某种意义上讲,这类似于在编程中使用繁忙循环,而不是为了执行多任务而导致的OS延迟。

   | 信号 | 方向 | 宽度 | 描述

  BURST_COUNT | 主O从I | 宽度var。 | 要执行的时序操作数量

  READ_DATAVALID | 主I从O | 宽度1。 | 外设使用此信号标记何时将数据提供给主机。可以任意声明,并且不能保证连续性。 带有操作数量为4的读操作在每次READ选通脉冲时会置位4次READ_DATAVALID

这种方法的主要优点是主机可以与从机通信,以便为每个事务读取或写入多个数据。实际上,对于读和写选通,BURST_COUNT信号都告诉外设事务将持续多长时间。
从机将声明WAIT_REQUEST,直到准备好接受操作为止。如果进行写操作,则仅在第一个选通脉冲上对BURST_COUNT和ADDRESS进行采样,此后,外设将认为WRITE的选通对所请求的字数有效,并将自动递增地址。对于读操作,在未声明WAIT_REQUEST时,执行的单个READ选通脉冲将通知外设读取BURST_COUNT个字,并通过声明所请求字数的READ_DATAVALID来返回。启动读取操作后,由外设决定是否接受更多操作,但通常应该至少有两个并发操作可以从流水线总线中受益。

(系统)Verilog模块的结构
SystemVerilog模块声明可以通过多种方式完成,但最喜欢的一种形式是可以使用参数的形式,以便可以在编译时自定义块输入。 例如:

module COUNTER #(
  pWIDTH=8
) (
  input                   iCLK,
  input                   iRESET,
  output reg [pWIDTH-1:0] oCOUNTER
);

endmodule

上面的代码只是定义了模块的原型并定义了其输入/输出端口,现在我们需要通过在模块头和endmodule语句之间添加一些代码来为其添加一些有用的逻辑。
由于我们从一个反例开始,让我们继续并编写一些实际实现它的代码:

module COUNTER #(
  pWIDTH=8
) (
  input               iCLK,
  input               iRESET,
  output reg [pWIDTH-1:0] oCOUNTER
);

always @(posedge iCLK)
begin
  if (iRESET) begin
    oCOUNTER<=0;
  end else begin
    oCOUNTER<= oCOUNTER+1;
  end
end

endmodule

上面的代码不言自明:在每个时钟正沿,如果我们看到输入iRESET为高,我们就将计数器复位,否则我们将其加一。使用复位信号将模块恢复到初始状态通常很有用,但并非必要。
现在……这很有趣,但是我们做了一些棘手的事情……我们将oCOUNTER声明为输出reg,这意味着我们说这不仅是Wire状态,而是具有内存。 这样,我们可以使用“<=”已注册的赋值,这意味着该赋值将保留下一个时钟周期。
我们可以执行的另一种方法是在模块声明中删除reg语句,并按以下方式定义计数器:

module COUNTER #(
  pWIDTH=8
) (
  input               iCLK,
  input               iRESET,
  output [pWIDTH-1:0] oCOUNTER
);

reg [pWIDTH-1:0] rCOUNTER;

always @(posedge iCLK)
begin
  if (iRESET) begin
    rCOUNTER<=0;
  end else begin
    rCOUNTER<= rCOUNTER+1;
  end
end

assign oCOUNTER=rCOUNTER;

endmodule

这基本上是相同的效果,但是我们单独定义了一个寄存器,对其进行了处理,然后“持续” 通过“=”的方式分配给输出信号。 此处的区别在于,虽然“<=”表示信号仅在时钟边沿发生变化=连续分配值,所以信号最终将随时随地变化,但是,如果我们像在示例中所做的那样将其随始终边沿变化的分配给寄存器, 最终的输出信号实际上只是一个别名。
有趣的是,分配与硬件描述语言中的任何其他语句一样都是并行的,这意味着它们在代码中的顺序不太相关,因为它们都是并行执行的,因此我们可以在Always块之前将oCOUNTER分配给rCOUNTER。 随后我们将回到这个问题,因为顺序并不重要……
连续分配的另一个有趣用途是可以创建逻辑方程式。 例如,我们可以通过以下方式重写计数器:

module COUNTER #(
  pWIDTH=8
) (
  input               iCLK,
  input               iRESET,
  output [pWIDTH-1:0] oCOUNTER
);

reg [pWIDTH-1:0] rCOUNTER;
wire [pWIDTH-1:0] wNEXT_COUNTER;

assign wNEXT_COUNTER = rCOUNTER+1;
assign oCOUNTER = rCOUNTER;

always @(posedge iCLK)
begin
  if (iRESET) begin
    rCOUNTER<=0;
  end else begin
    rCOUNTER<= wNEXT_COUNTER;
  end
end

endmodule

我们基本上仍然在做同样的事情,但是我们这样做的方式使它在逻辑上更加清晰。 基本上,我们连续将信号wNEXT_COUNTER赋值为rCOUNTER的值加1。 这意味着,一旦rCOUNTER更改值,wNEXT_COUNTER将(几乎)立即更改,但是rCOUNTER仅在下一个正时钟沿更新(因为它具有<=分配),因此结果仍然是rCOUNTER仅在时钟沿改变。

并行性和优先级

正如刚才所说,所有硬件描述语言都具有并行语句的概念,这意味着与顺序执行指令的软件编程语言相反,此处所有指令都在同一时间执行。 例如,如果我们编写一个包含以下代码的块,我们将看到寄存器在给定的时钟边沿一起改变:

reg [pWIDTH-1:0] rCOUNT_UP, rCOUNT_DOWN;

always @(posedge iCLK)
begin
  if (iRESET) begin
    rCOUNT_UP<=0;
    rCOUNT_DOWN<=0;
  end else begin
    rCOUNT_UP<= rCOUNT_UP+1;
    rCOUNT_DOWN<= rCOUNT_DOWN-1;
  end
end

当然,如果一切都以并行方式执行,我们需要有一种方法可以使语句顺序化,这可以通过创建一个简单的状态机来完成。 状态机是一种基于输入及其内部状态生成输出的系统。 从某种意义上说,我们的计数器已经是一个状态机,因为我们有一个输出(oCOUNTER)会根据该机的先前状态(rCOUNTER)进行更改,但是让我们做一些更有趣的事情,并创建一个状态机,该状态机将在开始时产生给定长度的脉冲。设备将具有三种状态:eST_IDLE,eST_PULSE_HIGH和eST_PULSE_LOW。 在eST_IDLE中,我们将对输入命令进行采样,当接收到输入命令时,我们将转换到eST_PULSE_HIGH,在此处我们将停留在给定的时钟数(我们将使用pHIGH_COUNT对其进行参数设置),然后将过渡到eST_PULSE_LOW,在其中保留 pLOW_COUNT,然后回到eST_IDLE…让我们看一下代码中的结果:

module PULSE_GEN #(
  pWIDTH=8,
  pHIGH_COUNT=240,
  pLOW_COUNT=40
) (
  input       iCLK,
  input       iRESET,
  input       iPULSE_REQ,
  output reg  oPULSE
);

reg [pWIDTH-1:0] rCOUNTER;
enum reg [1:0] {
  eST_IDLE,
  eST_PULSE_HIGH,
  eST_PULSE_LOW
} rSTATE;

always @(posedge iCLK)
begin
  if (iRESET) begin
    rSTATE<=eST_IDLE;
  end else begin
    case (rSTATE)
      eST_IDLE: begin
        if (iPULSE_REQ) begin
          rSTATE<= eST_PULSE_HIGH;
          oPULSE<= 1;
          rCOUNTER <= pHIGH_COUNT-1;
        end
      end
      eST_PULSE_HIGH: begin
        rCOUNTER<= rCOUNTER-1;
        if (rCOUNTER==0) begin
          rSTATE<= eST_PULSE_LOW;
          oPULSE<= 0;
          rCOUNTER<= pLOW_COUNT-1;
        end
      end
      eST_PULSE_LOW: begin
        rCOUNTER<= rCOUNTER-1;
        if (rCOUNTER==0) begin
          rSTATE<= eST_IDLE;
        end
      end
    endcase
  end
end

endmodule

在这里,我们看到了许多需要谈论的新东西。首先,我们使用枚举来定义rSTATE变量。这有助于将状态分配给易于理解的值,而不是硬编码的数字,并且具有可以轻松插入状态而无需重写所有状态机的优点。
其次,我们引入了case / endcase块,它允许我们根据信号的状态定义不同的行为。语法与C非常相似,因此大多数读者应该熟悉它。重要的是要注意,各个case块中的语句都是并行执行的,但是由于它们受所检查变量的不同值所限制,因此一次只能启用一个。查看eST_IDLE情况,我们会一直保持状态直到感觉到iPULSE_REQ变为高电平为止,在这种情况下,我们将状态更改,将计数器重置为高电平状态并开始输出脉冲。
请注意,由于已配置oPULSE给寄存器,它将保持其状态,直到再次分配。在下一个状态下,事情变得更加复杂……在每个时钟上,我们将计数器递减,然后,如果计数器达到0,我们还更改状态,将oPULSE更改为0,然后再次分配rCOUNTER。由于这两个赋值是并行执行的,因此我们需要知道这是什么意思,而且所有HDL指令都非常幸运,如果在同一寄存器上执行两个并行语句,则只有最后一个语句才能真正执行,因此我们写的含义是:通常我们会递减计数器,但是当计数器达到0时,我们会更改状态并将其重新初始化为pLOW_COUNT。


这时,在eST_PULSE_LOW中发生的事情变得非常清楚,因为我们只是将计数器减1,并在计数器达到0时立即返回eST_IDLE。请注意,当我们回到eST_IDLE时,rCOUNTER再次减1,因此结果是rCOUNTER将为0xff(或-1),但我们并不在乎,因为当我们收到iPULSE_REQ时会将其重置为适当的值。

尽管我们也可以在退出eST_PULSE_LOW时重置rCOUNTER,但在HDL中最好只执行真正必要的操作,因为更多的操作会消耗资源并降低硬件速度。在开始时,这似乎很危险,但是通过一些经验,将很容易看出这有什么帮助。同样的概念也适用于复位逻辑。除非确实有必要,否则根据其实现方式,它会消耗资源并降低系统速度,因此应谨慎使用。

一个真实的例子

现在,让我们来看看在Vidor中使用的简单外设实现PWM的真实示例。 我们要实现的是创建一个具有多个PWM输出的小模块,并可以定义每个PWM通道的相对相位差。
我们需要一个计数器和几个比较器,这些计数器会告诉我们何时超出给定值,以便切换输出。 我们还希望使PWM频率可编程,因此我们需要使计数器以与系统基准频率不同的频率运行,以便周期恰好是我们所需要的。 为此,使用预分频器,其基本上是另一个计数器,它以类似于UART的波特率发生器的方式将基本时钟分频为一个较低的值。
现在让我们看一下代码:

module PWM #(
  parameter pCHANNELS=16,
  parameter pPRESCALER_BITS=32,
  parameter pMATCH_BITS=32

)
(
  input                              iCLK,
  input                              iRESET,

  input [$clog2(2*pCHANNELS+2)-1:0]  iADDRESS,
  input [31:0]                       iWRITE_DATA,
  input                              iWRITE,

  output reg [pCHANNELS-1:0]         oPWM
);

// register declaration 
reg [pPRESCALER_BITS-1:0] rPRESCALER_CNT;
reg [pPRESCALER_BITS-1:0] rPRESCALER_MAX;

reg [pMATCH_BITS-1:0] rPERIOD_CNT;
reg [pMATCH_BITS-1:0] rPERIOD_MAX;
reg [pMATCH_BITS-1:0] rMATCH_H [pCHANNELS-1:0];
reg [pMATCH_BITS-1:0] rMATCH_L [pCHANNELS-1:0];
reg rTICK;

integer i;

always @(posedge iCLK)
begin
  // logic to interface with bus.
  // register map is as follows:
  // 0: prescaler value
  // 1: PWM period
  // even registers >=2: value at which PWM output is set high
  // odd registers >=2: value at which PWM output is set low
  if (iWRITE) begin
    // the following statement is executed only if address is >=2. case on iADDRESS[0]
    // selects if address is odd (iADDRESS[0]=1) or even (iADDRESS[0]=0)
    if (iADDRESS>=2) case (iADDRESS[0])
      0: rMATCH_H[iADDRESS[CLogB2(pCHANNELS):1]-1]<= iWRITE_DATA;
      1: rMATCH_L[iADDRESS[CLogB2(pCHANNELS):1]-1]<= iWRITE_DATA;
    endcase
    else begin
      // we get here if iADDRESS<2
    case (iADDRESS[0])
    0: rPRESCALER_MAX<=iWRITE_DATA;
    1: rPERIOD_MAX<=iWRITE_DATA;
  endcase
    end
  end

  // prescaler is always incrementing      
  rPRESCALER_CNT<=rPRESCALER_CNT+1;
  rTICK<=0;
  if (rPRESCALER_CNT>= rPRESCALER_MAX) begin
    // if prescaler is equal or greater than the max value
    // we reset it and set the tick flag which will trigger the rest of the logic
    // note that tick lasts only one clock cycle as it is reset by the rTICK<= 0 above
    rPRESCALER_CNT<=0;
    rTICK <=1;
  end
  if (rTICK) begin
    // we get here each time rPRESCALER_CNT is reset. from here we increment the PWM
    // counter which is then clocked at a lower frequency.
    rPERIOD_CNT<=rPERIOD_CNT+1;
    if (rPERIOD_CNT>=rPERIOD_MAX) begin
      // and of course we reset the counter when we reach the max period.
      rPERIOD_CNT<=0;
    end
  end

  // this block implements the parallel comparators that actually generate the PWM outputs
  // the for loop actually generates an array of logic that compares the counter with
  // the high and low match values for each channel and set the output accordingly.
  for (i=0;i<pCHANNELS;i=i+1) begin
    if (rMATCH_H[i]==rPERIOD_CNT)
      oPWM[i] <=1;
    if (rMATCH_L[i]==rPERIOD_CNT)
      oPWM[i] <=0;
  end
end

endmodule

这里有许多新东西需要学习,因此让我们从模块声明开始。在这里,我们使用内置函数$clog2来建立所需的地址总线位宽。目的是将地址范围限制为寄存器所需的最小值,因此例如如果我们要10个通道,则总共需要22个地址。由于每个地址位使我们可以使用的地址数增加一倍,因此我们总共需要5位,从而使地址总数达到32个。为了使此参数化,我们将iADDRESS宽度定义为$ clog2(2 * pCHANNELS + 2),并将寄存器定义为二维数组。

实际上有两种方法可以创建多维数组,在这里我们使用的是“非打包的”数组,该方法未通过在寄存器声明的左侧添加索引,每个寄存器定义为单独的实体。 另一种方式我们在此示例中未使用,是“打包的”方式,其中索引都位于声明的左侧,结果是2维数组可以被视为包含所有的寄存器的单个“大寄存器”。

另一个有趣的技巧是如何定义处理寄存器的逻辑。 首先,我们只是实现写寄存器,因此您将找不到iREAD和iREAD_DATA信号。其次,我们想设置一组参数寄存器,它其中的前两个寄存器一直存在,而其余的则根据想实现的通道数量去动态的定义和处理。  为了做到这点我们注意到,在二进制数中数字的最低位定义了该数字是奇数还是偶数。 由于每个通道有两个寄存器,这很方便,因为我们可以根据是否在地址2以下来区分行为。如果我们在地址2以下,我们采用公共寄存器,即预分频器计数和计数器周期。 如果我们高于2,则使用LSB来决定是否要写入高比较器或低比较器的值。

相关文章

网友评论

      本文标题:Arduino学习:FPGA HDL基础

      本文链接:https://www.haomeiwen.com/subject/nesbgctx.html