FPGA流水线设计的策略

发现一篇非常好的讲FPGA中流水线设计的文章,搬运并翻译一下。

本文是https://zipcpu.com/blog/2017/08/14/strategies-for-pipelining.html的中文翻译。

学习FPGA的学生常常面临一个棘手的问题,FPGA中所有而事情都是并行发生的。

许多同学来自计算机科学专业,他们理解算法如何工作,一个操作需要在另一个操作之后顺序执行。他们很难理解算法中的每一步都对应着一块逻辑电路,需要在相应的时钟周期执行——无论是否使用。

解决操作顺序的一个方法是创建一个巨大的状态机。FPGA往往会同时创建所有状态的逻辑,并且仅在每个时周期结束的时候选择正确的答案。因此,状态机可以非常想我们讨论过的简单的多ALU(算术逻辑单元)。

另一方面,如果FPGA无论如何都会实现操作的所有逻辑,那么为什么不讲这些操作安排成一个序列,每个阶段都做一些有用的事情呢?这种方法将算法重新排列为流水线。流水线往往比状态机方法更快速完成相同的算法,甚至在资源利用方面有时也更高效,尽管这不一定。

数字逻辑流水线的难点在于,即使流水线的输入尚未有效,流水线也会运行并产生输出

让我们讨论几种不同的处理流水线逻辑信号的方法。一般来说,没有一种策略适用于所有情况。选择的策略将取决于算法的需求,以及数据源(输入)和目的地(输出)。

我们将讨论最简单到最复杂的几种策略。

全局有效信号

我们将讨论的第一种流水线处理策略是使用全局有效信号。在每个阶段,当全局有效信号为真时,进入流水线的数据就是有效的。同样,每个阶段完成所需的时钟周期数不得超过有效信号之间的时钟周期数。我喜欢使用CE(Clock Enable,时钟使能)信号来表示这种有效逻辑。因此,图1显示了这种通信方式的框图。

图1:带有全局有效信号的流水线

基本规则如下:

  1. 有一个与时钟同步的全局CE线。每当有新数据准备好时,CE为真。
  2. 只有在CE为真时,逻辑才被允许。

从而形成类似图2的波形图。

图2:带有全局有效信号的流水线

这种方法的妙处在于不需要真正的流水线逻辑。每个阶段只需等待全局有效信号为真,然后执行逻辑。

1
2
3
4
5
always @(posedge i_clk)
if (i_ce)
begin
output <= (some function of)(i_input);
end // else *NOTHING* 。如果没有CE=1,则不会发生任何事情。

然而,你可能会很快发现,这种方法并不能满足所有流水线的需求。虽然它不能满足所有流水线的需求,但它确实能解决一类重要的问题:信号处理。

在典型的信号处理应用中,数据要么从模数(A/D)转换器进入FPGA,要么从FPGA输出到数模(D/A)转换器,或者同时进行这两种操作。采样率决定何时CE信号需要为高电平,系统会处理和消除任何瞬态,数字逻辑工程师的任务是在整个过程中对进入FPGA的样本进行必要的处理和操作。

由于数据以固定速率穿过FPGA,并且它从不会突然改变速度,全局有效信号非常适用于它。

应用包括数字滤波、数字锁相环、数控振荡器等。实际上,任何以固定数据速率工作的东西都适合用这种方法。

实际上,我们的重采样器一直是基于全局有效信号的概念,它们只是必须处理两个不同的有效信号:一个信号每个输入样本保持一个时钟,另一个信号每个输出样本保持一个时钟。

减少延迟的移动CE方法

上面讨论的全局有效信号有两个基本问题。第一个问题是无法知道输出样本是否“有效”。第二个问题是整个操作依赖于均匀的时钟来产生CE信号。如果数据是以突发方式产生的,而且您不仅想知道何时输出有效,还想知道输出是否有效,该怎么办?在这种情况下,需要采用另一种方法。

我将这种第二种方法称为“移动CE”方法。基本上,流水线中的每个阶段都会向前传播CE,如图3所示。

图3:带有流动CE的流水线

  1. 只要CE信号为真,与之相关的数据也必须有效。
  2. 在每个处理阶段结束时,必须产生一个CE信号,连同该阶段的输出数据。
  3. 必须将CE信号初始化为零。此外,如果要使用任何复位,则必须在任何复位时将CE设为零。(复位时数据不关心,但CE线必须设为零。)
  4. 除非有CE信号,否则不允许任何变化。因此,只有在i_ce(流水线阶段的输入CE线)高时,才会引用输入数据。
  5. 最后,每块逻辑必须随时准备好一个新值进入流水线。这种流水线策略无法处理停顿

这种类型逻辑的波形图可能如下图4所示。

图4:带有流动CE的流水线

用Verilog表示:

1
2
3
4
5
6
7
8
9
10
initial    o_ce = 1'b0;
always @(posedge i_clk)
if (i_reset)
o_ce <= 1'b0;
else
o_ce <= i_ce;
always @(posedge i_clk)
if (i_ce)
o_output <= ... // 一些关于i_input的函数
// else *NOTHING*. 只有reset或i_ce的时候才允许改变

这种方法非常适用于可以分成不超过单个输入有效信号的阶段的流水线。同样,它也适用于没有阶段依赖于未来结果的反馈的情况。换句话说,如果没有任何东西需要等待,那么这种流水线方法效果非常好。

这种流水线方法的应用包括逻辑乘法,傅里叶变换处理,视频处理,变速箱等。实际上,我们在Hexbus调试总线中使用了这种方法将输入处理链连接在一起。然而,您可能注意到,这种方法在输出处理链中不起作用。问题在于UART串口发送器传输字符所需的时间超过了一个时钟周期,因此需要另一种流水线信号方法——一种允许流水线末端控制流水线速率的方法。

简单的握手

流水线流动CE方法的最大问题是无法处理听者未准备好的情况。以UART串口发送器为例,您可以创建一个填充发送器的流水线,但当发送器繁忙时怎么办?这需要一种简单的握手方法,我将在本节中描述。

基本握手依赖于一对信号——一个来自当前设备,另一个来自流水线中的下一个设备。我们将这些信号称为STB(或valid)和BUSY,但根据接口的不同,这些信号有多种其他名称。图5显示了一个简单的流水线,只有两个阶段,握手信号在其中工作。

图5:简单握手流水线的框图

基本规则如下:

  1. 只要STB线为真且BUSY线为假,事务就会发生。
  2. 接收流水线阶段需要小心,不要在不准备接收数据时拉低BUSY线。
  3. 数据准备好发送时应提高STB线。数据源不应等待BUSY为假再提高STB线。
    不等待BUSY为真是为了避免死锁。通过独立于BUSY设置STB,消除了两者之间的依赖关系。
  4. 同样,BUSY线应在不忙状态下拉低。
    虽然许多AXI演示实现会在AXI_*READY线为假时拉低(其等效于BUSY线为真),但这只会在不必要的时钟上减慢您的交互。记住,流水线逻辑的目标之一是速度。在不需要时使BUSY为真会减慢流水线。
  5. 一旦STB被拉高,在事务发生后的下一个时钟沿到来之前,传输的数据不能更改。也就是说,使用(STB)&&(!BUSY)确定是否需要更改。
  6. 任何时间STB为假时,数据线处于“dont’t care”状态。
  7. STBBUSY线必须初始化为零。如果需要复位或清除流水线操作,则这些信号需要在复位或清除时回到零。
    由于数据线将处于“dont’t care”状态,因此复位时它们不需要有任何值。

从逻辑的角度看,这种握手的波形图类似于图6。

图6:简单握手流水线信号

请特别注意“(Transaction)”线。这条线是理解波形图的关键。它由(STB)&&(!BUSY)的组合逻辑给出。这条线是移动CE方法中的CE线。当(Transaction)线为高电平时,数据是有效的(因为STB为高电平),处理可以前进一步。

在构建慢速硬件的控制器时,我多次使用了这种方法。在这些情况下,接收器通常看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
initial o_busy = 1'b0;
always @(posedge i_clk)
if (i_reset)
begin
o_busy <= 1'b0;
state <= IDLE_STATE;
end if ((i_ce)&&(!o_busy)) begin
// 我们刚刚接收到一个输入样本,进入这个控制器
// 将o_busy设置为1,并开始处理这个输入。
o_busy <= 1'b1;
state <= START_STATE;
data <= i_data;
// ...
end else case(state)
// A state machine is used to handle an interaction
// with the hardware now that a request has been made.
// 一个用来与当前发送请求的硬件交互的状态机
// ...
FINAL_STATE: begin
o_busy <= 1'b0;
state <= IDLE_STATE;
// ... 其他逻辑
end
// default:
endcase

你可能已经猜到了,我的UART发送器使用了这种方法。在UART发送器的测试中,你也可以找到一些与这样一个发送器交互的例子。或者,你可能会在Hexbus模块的发送部分中找到这种流水线的使用方法。

使用UART例子的问题在于,它并没有真正捕捉到任何中间流水线阶段所需的逻辑,只捕捉到了最后阶段的。

在中间阶段,有两种处理方式。要么对BUSY信号进行寄存,从而在任意两个事务之间发生流水线停顿;或者使用组合逻辑创建BUSY信号。使用组合逻辑创建的BUSY信号(如下面的代码示例所示)有一个问题,即随着你在流水线中向后移动,用于确定BUSY信号的延迟会累积。这会减慢逻辑,因此当组合延迟接近你的时钟周期时,这种方法就变得不理想了。另一方面,如果这部分是在逻辑流水线末端的UART或其他任何慢速外围设备(如闪存、ICAPE2 OLEDrgb等),那么你可能无需在意因BUSY信号计算而丢失的时钟周期。

这里是构建一个流水线组件的例子,它使用了这种握手协议作为组件的输入和输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
initial r_busy = 1'b0;
initial o_stb = 1'b0;
always @(posedge i_clk)
if (i_reset)
begin
// reset时,必须清除busy和stb
// 此时数据为don't care
r_busy <= 1'b0;
o_stb <= 1'b0;
end if (!o_busy)
begin
o_stb <= 0;
if (i_stb)
begin
// 刚刚发生了一个输入
r_busy <= 1'b1;
// 在这里开始你的逻辑 ...
//
end
// 否则,保持空闲状态
end else if ((o_stb)&&(!i_busy))
begin
// 刚刚发生了一个输出
r_busy <= 1'b0;
o_stb <= 1'b0;
end else if (!o_stb) begin
// o_busy为真,可以在这里进行任何必要的逻辑操作
if (your logic is complete)
o_stb <= 1'b1;
end // 否则,等待下一阶段接受我们的输出数据,才能继续。

最后一步是设置o_busy信号。我们用r_busy记录我们的组件忙碌的时间。设置o_busy是为了不浪费空闲的时钟周期,尽管需要一些组合逻辑(如借用的时钟时间)来处理。

1
assign	o_busy = (r_busy) && (!o_stb || i_busy);

这个示例很容易修改,只需通过r_busy设置o_busy,就可以消除组合累计延迟。

1
assign o_busy = r_busy;

这将创建一个空闲周期,但它也会解决组合时间累积问题。

这种握手方式的例子很多。例如,Wishbone总线有一种交互形式,使用了这种握手方式,尽管它稍微改变了信号名称。虽然STB名称保持不变,但Wishbone总线使用STALL作为其BUSY线的名称。同样,AXI总线规范也使用了这种揘认可方式。事实上,它在五个独立的握手通道上都使用了这种形式。AXI使用*AXI_VALID代替STBAXI_*READY代替!BUSY。我们之前也已经讨论了主dbgbus模块的传输部分,即返回处理链,作为另一个例子。

带缓冲的握手

咕咕咕