在块RAM中设计健壮的VHDL环形缓冲区FIFO
环形缓冲区是顺序编程语言中创建队列的常用结构,同样也可以在硬件中实现。本文将介绍如何用VHDL在块RAM中实现环形缓冲区FIFO。
实现FIFO时需要做许多设计决策:需要什么样的接口?资源是否受限?是否需要抗过读和过写能力?延迟是否可接受?这些都是设计FIFO时会考虑的问题。
网上有许多免费的FIFO实现,也有像Xilinx LogiCORE这样的FIFO生成器。但许多工程师仍倾向于自己实现FIFO。因为尽管它们都执行基本的入队和出队任务,但在细节上却可能有巨大差异。
环形缓冲区的工作原理
环形缓冲区是一种FIFO实现,它使用连续内存存储缓冲数据,且数据移动最少。新元素从写入时起保持在同一内存位置,直到被读取并从FIFO中移除。
使用两个计数器跟踪FIFO中数据的位置和数量。这些计数器指向数据存储内存空间起始处的偏移量。在VHDL中,这将是数组单元的索引。本文将这两个计数器称为指针。
这两个指针分别是头指针和尾指针。头指针始终指向将包含下一个写入数据的内存槽,而尾指针指向将从FIFO读取的下一个元素。虽然有其他变体,但我们将使用这种方式。
空状态
如果头指针和尾指针指向同一元素,表示FIFO为空。上图展示了一个8槽FIFO的例子,头尾指针均指向元素0,表明FIFO为空。这是环形缓冲区的初始状态。
注意,如果两个指针都在另一个索引(如3),FIFO仍为空。每次写入,头指针前移一位;每次读取,尾指针前移一位。
当任一指针到达最高索引时,下一次写入或读取会使指针回绕到最低索引。这就是环形缓冲区的优势:数据不动,只有指针移动。
头指针领先尾指针
上图显示同一环形缓冲区在五次写入后的状态。尾指针仍在槽0,头指针移至槽5。含数据的槽在图中标为浅蓝色。尾指针指向最老元素,头指针指向下一个空闲槽。
当头指针索引大于尾指针时,可通过头指针减尾指针计算环形缓冲区元素数。上例中得5个元素。
尾指针领先头指针
仅当头指针领先尾指针时,用头减尾才有效。上图中头指针在索引2,尾指针在索引5,直接相减得2-5=-3,无意义。
解决方法是用FIFO总槽数(本例为8)偏移头指针。计算变为(2+8)-5=5,即正确答案。
尾指针永远追赶头指针,这就是环形缓冲区的工作方式。一半时间尾指针索引大于头指针。数据存储在两者之间,如图中浅蓝色所示。
满状态
环形缓冲区满时,尾指针指向头指针后的下一个索引。这种方案导致无法使用所有槽存储数据,至少需保留一个空槽。上图显示环形缓冲区满的情况,空闲但不可用的槽标为黄色。
也可用专用的空/满信号指示满状态,这允许所有内存槽存数据,但需要额外寄存器和查找表(LUT)逻辑。因此我们采用保留一个空槽方案实现环形缓冲区FIFO,因为这仅浪费较便宜的块RAM。
环形缓冲区FIFO实现
定义FIFO的接口信号将限制环形缓冲区的可能实现方式。本例使用经典读/写使能和空/满/有效接口的变体。
输入侧有写数据总线,承载要推入FIFO的数据;还有写使能信号,置位时FIFO采样输入数据。
输出侧有FIFO控制的读数据和读有效信号,以及下游用户控制的读使能信号。
空和满控制信号属于经典FIFO接口,我们也使用它们。它们由FIFO控制,用于向读写方传达FIFO状态。
背压
等到FIFO空或满才采取行动的问题在于,接口逻辑没有反应时间。时序逻辑按时钟周期工作,时钟上升沿将设计中的事件有效地分离为时间步长。
解决方案是包含近空和近满信号,它们比原始信号早一个时钟周期。这给外部逻辑留出反应时间,即使在连续读写时也是如此。
在我们的实现中,这些前导信号命名为empty_next和full_next,只是因为我倾向于用后缀而非前缀命名。
实体声明
下图展示了我们环形缓冲区FIFO的实体。除端口中的输入输出信号外,还有两个泛型常量。RAM_WIDTH泛型定义输入输出字的位数,即每个内存槽包含的位数。
RAM_DEPTH泛型定义为环形缓冲区预留的槽数。因保留一个槽指示满状态,FIFO容量为RAM_DEPTH - 1。RAM_DEPTH常量应与目标FPGA上的RAM深度匹配。块RAM基元内未使用的RAM将被浪费,无法与FPGA中其他逻辑共享。
entity ring_buffer is generic ( RAM_WIDTH : natural; RAM_DEPTH : natural ); port ( clk : in std_logic; rst : in std_logic; -- Write port wr_en : in std_logic; wr_data : in std_logic_vector(RAM_WIDTH - 1 downto 0); -- Read port rd_en : in std_logic; rd_valid : out std_logic; rd_data : out std_logic_vector(RAM_WIDTH - 1 downto 0); -- Flags empty : out std_logic; empty_next : out std_logic; full : out std_logic; full_next : out std_logic; -- The number of elements in the FIFO fill_count : out integer range RAM_DEPTH - 1 downto 0 ); end ring_buffer;
除时钟和复位外,端口声明包含经典的数据/使能读写端口。上游和下游模块用它们向FIFO推入新数据、弹出最老元素。
rd_valid信号在rd_data端口包含有效数据时由FIFO置位。该事件在rd_en脉冲后一个时钟周期发生。文末将详述原因。
接着是FIFO设置的空/满标志。empty_next在剩余1或0个元素时置位,而empty仅在0个元素时激活。同理,full_next指示还能容纳1或0个元素,full仅在无法再容纳数据时置位。
最后是fill_count输出。这是一个整数,反映FIFO当前存储的元素数。我们内部会用到它,通过实体暴露出来基本无额外开销,用户实例化时可选择留空该信号。
声明区
在VHDL文件的声明区,我们将声明自定义类型、子类型、若干信号及供环形缓冲区模块内部使用的过程。
type ram_type is array (0 to RAM_DEPTH - 1) of std_logic_vector(wr_data'range); signal ram : ram_type; subtype index_type is integer range ram_type'range; signal head : index_type; signal tail : index_type; signal empty_i : std_logic; signal full_i : std_logic; signal fill_count_i : integer range RAM_DEPTH - 1 downto 0; -- Increment and wrap procedure incr(signal index : inout index_type) is begin if index = index_type'high then index <= index_type'low; else index <= index + 1; end if; end procedure;
首先声明新类型建模RAM。ram_type是向量数组,由泛型输入决定大小。下一行用该类型声明ram信号,保存环形缓冲区数据。
接下来声明index_type,整数子类型。其范围间接受RAM_DEPTH泛型控制。随后用该索引类型声明头、尾指针信号。
然后是实体信号的内部副本声明,基名相同但后缀_i表示内部使用。这种做法避免在实体信号上使用inout模式(虽效果相同但被视为不良风格)。
最后声明incr过程,接受index_type信号参数。该子程序用于头尾指针增量并回绕。头尾指针是整数子类型,通常不支持回绕行为,我们用过程解决此问题。
并发语句
架构体顶部声明并发语句。我习惯将这些单行信号赋值放在普通进程前,因它们易被忽略。并发语句实质是一种进程,详见:
如何在VHDL中创建并发语句
-- Copy internal signals to output empty <= empty_i; full <= full_i; fill_count <= fill_count_i; -- Set the flags empty_i <= '1' when fill_count_i = 0 else '0'; empty_next <= '1' when fill_count_i <= 1 else '0'; full_i <= '1' when fill_count_i >= RAM_DEPTH - 1 else '0'; full_next <= '1' when fill_count_i >= RAM_DEPTH - 2 else '0';
第一块并发赋值将实体信号的内部版本复制到输出。这些行确保实体信号在同一时间跟随内部版本,但在仿真中有一个delta周期延迟。
第二块也是最后一块并发语句赋值输出标志,指示环形缓冲区的满/空状态。计算基于RAM_DEPTH泛型和fill_count信号。RAM深度是不变常量,因此标志仅随填充计数更新而变化。
更新头指针
头指针基本功能是外部写使能信号置位时增量。我们通过将head信号传给前述incr过程实现。
PROC_HEAD : process(clk) begin if rising_edge(clk) then if rst = '1' then head <= 0; else if wr_en = '1' and full_i = '0' then incr(head); end if; end if; end if; end process;
代码额外包含and full_i = '0'防止过写。若确定数据源绝不在FIFO满时写入,可省略此逻辑。无保护时,过写会导致环形缓冲区再次变空。
若FIFO满时头指针仍增量,头指针将指向尾指针同一元素,模块将“遗忘”所含数据,FIFO填充显示为空。
在增量头指针前评估full_i信号,仅会遗忘被覆盖的值。我认为这方案更好。但无论如何,若发生过写,表明模块外部有故障。
更新尾指针
尾指针增量方式类似,但以read_en输入为触发。同理,包含and empty_i = '0'防止过读。
PROC_TAIL : process(clk) begin if rising_edge(clk) then if rst = '1' then tail <= 0; rd_valid <= '0'; else rd_valid <= '0'; if rd_en = '1' and empty_i = '0' then incr(tail); rd_valid <= '1'; end if; end if; end if; end process;
此外,每次有效读取时脉冲rd_valid信号。读数据在rd_en置位后下一个时钟周期有效(若FIFO非空)。虽此信号非必需,但为方便保留。实例化模块时若留空,综合会优化掉rd_valid。
推断块RAM
要让综合工具推断块RAM,必须在无复位的同步进程中声明读写端口。我们每个时钟周期读写RAM,由控制信号处理数据使用。
PROC_RAM : process(clk) begin if rising_edge(clk) then ram(head) <= wr_data; rd_data <= ram(tail); end if; end process;
此进程不知下次写入何时发生,但无需知道。我们持续写入。当head因写入而增量时,开始写入下一槽,有效锁存写入的值。
更新填充计数
fill_count信号用于生成满/空信号,进而防止FIFO过写和过读。填充计数器由组合进程更新,敏感于头尾指针,但这些信号仅在时钟上升沿更新,因此填充计数也在时钟沿后立即变化。
PROC_COUNT : process(head, tail) begin if head < tail then fill_count_i <= head - tail + RAM_DEPTH; else fill_count_i <= head - tail; end if; end process;
填充计数即头指针减尾指针。若尾指针索引大于头指针,需加上RAM_DEPTH常量得当前环形缓冲区元素数。
环形缓冲区FIFO完整VHDL代码
library ieee; use ieee.std_logic_1164.all; entity ring_buffer is generic ( RAM_WIDTH : natural; RAM_DEPTH : natural ); port ( clk : in std_logic; rst : in std_logic; -- Write port wr_en : in std_logic; wr_data : in std_logic_vector(RAM_WIDTH - 1 downto 0); -- Read port rd_en : in std_logic; rd_valid : out std_logic; rd_data : out std_logic_vector(RAM_WIDTH - 1 downto 0); -- Flags empty : out std_logic; empty_next : out std_logic; full : out std_logic; full_next : out std_logic; -- The number of elements in the FIFO fill_count : out integer range RAM_DEPTH - 1 downto 0 ); end ring_buffer; architecture rtl of ring_buffer is type ram_type is array (0 to RAM_DEPTH - 1) of std_logic_vector(wr_data'range); signal ram : ram_type; subtype index_type is integer range ram_type'range; signal head : index_type; signal tail : index_type; signal empty_i : std_logic; signal full_i : std_logic; signal fill_count_i : integer range RAM_DEPTH - 1 downto 0; -- Increment and wrap procedure incr(signal index : inout index_type) is begin if index = index_type'high then index <= index_type'low; else index <= index + 1; end if; end procedure; begin -- Copy internal signals to output empty <= empty_i; full <= full_i; fill_count <= fill_count_i; -- Set the flags empty_i <= '1' when fill_count_i = 0 else '0'; empty_next <= '1' when fill_count_i <= 1 else '0'; full_i <= '1' when fill_count_i >= RAM_DEPTH - 1 else '0'; full_next <= '1' when fill_count_i >= RAM_DEPTH - 2 else '0'; -- Update the head pointer in write PROC_HEAD : process(clk) begin if rising_edge(clk) then if rst = '1' then head <= 0; else if wr_en = '1' and full_i = '0' then incr(head); end if; end if; end if; end process; -- Update the tail pointer on read and pulse valid PROC_TAIL : process(clk) begin if rising_edge(clk) then if rst = '1' then tail <= 0; rd_valid <= '0'; else rd_valid <= '0'; if rd_en = '1' and empty_i = '0' then incr(tail); rd_valid <= '1'; end if; end if; end if; end process; -- Write to and read from the RAM PROC_RAM : process(clk) begin if rising_edge(clk) then ram(head) <= wr_data; rd_data <= ram(tail); end if; end process; -- Update the fill count PROC_COUNT : process(head, tail) begin if head < tail then fill_count_i <= head - tail + RAM_DEPTH; else fill_count_i <= head - tail; end if; end process; end architecture;
上述代码展示了环形缓冲区FIFO的完整代码。填写下方表单可立即获取ModelSim工程文件及测试平台。
测试平台
FIFO在简单测试平台中实例化以演示工作原理。可通过下方表单下载测试平台源码及ModelSim工程。
泛型输入设为:
- RAM_WIDTH: 16
- RAM_DEPTH: 256
测试平台先复位FIFO。复位释放后,向FIFO写入顺序值(1-255)直到满。最后在测试完成前清空FIFO。
下图显示测试平台完整运行的波形。fill_count信号在波形中以模拟值显示,更直观展示FIFO填充电平。

仿真开始时头、尾指针和填充计数均为0。full信号置位时,头指针值为255,fill_count也为255。填充计数仅达255尽管RAM深度为256,因我们用保留一个空槽法区分满空,如前所述。
在停止写入转为读取的转折点,头指针值冻结,尾指针和填充计数开始递减。仿真结束时FIFO为空,头尾指针均为255,填充计数为0。
此测试平台仅供演示,不具备自检行为或验证FIFO输出正确性的逻辑。
下周文章将使用此模块深入探讨约束随机验证。这是一种不同于常用定向测试的策略。简言之,测试平台将与DUT(被测器件)进行随机交互,DUT行为需由单独测试平台进程验证。最后,当预定义事件发生一定次数后,测试完成。
点击阅读后续博文:
约束随机验证
在Vivado中综合
我在Xilinx Vivado中综合了环形缓冲区,因它是最流行的FPGA实现工具。但应适用于所有具有双端口块RAM的FPGA架构。
要将环形缓冲区作为独立模块实现,必须给泛型输入赋值。Vivado中通过Settings → General → Generics/Parameters菜单设置,如下图所示。

RAM_WIDTH设为16,同仿真。但RAM_DEPTH设为2048,因这是我选用的Xilinx Zynq架构中RAMB36E1基元的最大深度。选较低值仍使用同等数量块RAM;较高值会导致使用多个块RAM。
下图显示Vivado报告的实现后资源使用。环形缓冲区确实消耗一个块RAM和少量LUT及触发器。

省去valid信号
你可能会问:rd_en和rd_valid间一个时钟周期延迟真有必要吗?毕竟置位rd_en时数据已在rd_data上。能否直接使用该值,让环形缓冲区在读取时跳到下一元素?
严格说,valid信号非必需。我保留它仅为方便。关键是必须等到置位rd_en后的时钟周期,否则RAM没时间反应。
FPGA块RAM是全同步组件,读写都需要时钟沿。读写时钟可来自不同源,但必须有时钟沿。且RAM输出到下一级寄存器(触发器)间不能有逻辑,因为给RAM输出打拍的寄存器在块RAM基元内部。
上图展示数值从我们环形缓冲区的wr_data输入,经RAM传播,最终出现在rd_data输出的时序图。因每个信号在上升沿采样,从驱动写端口到出现在读端口需三个时钟周期。接收模块利用该数据还需额外一个周期。
降低延迟
有方法缓解此问题,但以额外FPGA资源为代价。试着从环形缓冲区读端口减少一个时钟周期延迟。下面代码片段将rd_data输出从同步进程改为敏感于ram和tail信号的组合进程。
PROC_READ : process(ram, tail) begin rd_data <= ram(tail); end process;
遗憾的是,此代码无法映射到块RAM,因为RAM输出与rd_data上第一个下游寄存器间可能有组合逻辑。
下图显示Vivado报告的资源使用。块RAM被LUTRAM取代——一种用LUT实现的分布式RAM。LUT用量从37飙升至947。查找表和触发器比块RAM昂贵,这正是我们有块RAM的原因。

用块RAM实现环形缓冲区FIFO有很多方法。或许用其他设计能省掉额外时钟周期,但会增加额外支持逻辑。对大多数应用,本文提出的环形缓冲区已足够。
更新:
如何使用AXI ready/valid握手在块RAM中创建环形缓冲区FIFO
下篇博文将用约束随机验证为环形缓冲区模块创建更好的测试平台。
点击阅读后续博文:
约束随机验证
VHDL