メモリ、ブロックRAMとFIFO
分散RAMとブロックRAMの違い、合成が正しいプリミティブを推論するようにRAMとROMを書く方法、リードビフォアライトとライトファースト、そしてfullとemptyフラグを備えた同期FIFOを解説します。
このレッスンで学ぶこと
- 分散RAMとブロックRAMの違いと、それぞれが推論される条件を説明できる
- ブロックRAMにマッピングされるシングルポートRAMとシンプルデュアルポートRAMを、VerilogとVHDLで書ける
- リードビフォアライトとライトファーストの動作、およびブロックRAMの1サイクルの読み出しレイテンシを説明できる
- hexファイルからROMを初期化し、正しいfullとemptyフラグを持つ同期FIFOを作れる
レジスタはそれぞれ数ビットを保持します。設計が数千バイト、1フレーム分のピクセル、ルックアップテーブル、パケットのキューを保持する必要があるときはメモリが必要で、すべてのFPGAファミリには2種類のメモリが内蔵されています。このレッスンでは、それらが何か、合成ツールが正しい方に変換してくれるHDLの書き方、そして設計上避けて通れない動作、つまりブロックRAMの読み出しには1クロックサイクルかかることを説明します。最後は、メモリを使った部品の中で最も有用なFIFOで締めくくります。レッスン03とレッスン04を終えている必要があります。
2種類のメモリ
分散RAMはLUTそのものから作られます。LUTはすでに小さなメモリであり、6入力LUTなら64ビットで、入力がアドレスの役割を果たします。ほとんどのファミリではファブリックから実行時にそのビットへ書き込めるため、数個のLUTが小さなRAMになります。読み出し経路は組み合わせ論理です。アドレスを与えると、クロックに関係なくLUT遅延の後にデータが現れます。分散RAMは、レジスタファイルや短いシフトレジスタのような小さく幅の広い構造には安価ですが、それより大きなものには乏しい資源です。格納する1ビットごとに、ロジックに使えないLUTが1つ増えるからです。
ブロックRAM(BRAM)は専用のハードブロックで、AMD 7 Seriesでは1ブロックあたり通常18 kbまたは36 kb、Alteraではファミリによって9 kbまたは20 kb、Gowin GW1Nでは18 kbで、デバイスあたり数十から数千個あります。各ブロックは1つまたは2つのポートを持ち、各ポートは独自のアドレス、データ、イネーブルを持ち、そして各ポートは同期式です。アドレスはクロックエッジで取り込まれ、データはそのエッジの後に出てきます。組み合わせ読み出しはありません。これがブロックRAMについて最も重要な事実であり、初心者の混乱の大半の原因です。
| 特性 | 分散RAM | ブロックRAM |
|---|---|---|
| 構成要素 | LUT | 専用SRAMブロック |
| 典型的なサイズ | LUTグループあたり数十〜数百ビット | ブロックあたり9 kb〜36 kb |
| 読み出し | 非同期(組み合わせ)またはレジスタ付き | 同期のみ、1サイクルのレイテンシ |
| ポート | 書き込み1、読み出し1以上 | ほとんどのファミリで真のデュアルポート |
| 向いている用途 | レジスタファイル、小さなFIFO、シフトレジスタ | バッファ、テーブル、ラインバッファ、大きなFIFO |
| コスト | ロジック資源を奪う | 固定の別予算 |
合成ツールはHDLの書き方からどちらを使うかを決めます。レジスタ付きの読み出しを持つメモリ配列は、あるサイズの閾値を超えるとブロックRAMになります。組み合わせ読み出しを持つメモリはブロックRAMにはなれず分散RAMになり、それが大きければ警告が出て非常に遅い設計になります。レジスタ付きの読み出しを書けば、あとはツールがやってくれます。
シングルポートRAMの推論
これが標準的なパターンです。1ポート、1クロック、書き込みイネーブル、そして同じエッジでレジスタに取り込まれる読み出しです。
// Single-port RAM, synchronous read, read-before-write (the default
// behaviour most tools infer).
module ram_sp #(
parameter integer DW = 8,
parameter integer AW = 10 // 1024 words
) (
input wire clk,
input wire we,
input wire [AW-1:0] addr,
input wire [DW-1:0] wdata,
output reg [DW-1:0] rdata
);
reg [DW-1:0] mem [0:(1 << AW) - 1];
always @(posedge clk) begin
if (we)
mem[addr] <= wdata;
rdata <= mem[addr]; // old contents on a write cycle
end
endmodule
宣言reg [DW-1:0] mem [0:(1 << AW) - 1];は、8ビット語1,024個の配列です。書き込みは、weの下での1要素へのノンブロッキング代入です。読み出しは、1要素からレジスタへのノンブロッキング代入で、条件なしに毎サイクル行われます。この無条件のレジスタ付き読み出しこそ、ツールが探しているものです。
アドレスを与えて同じサイクルでデータを期待する設計は、代わりに前のアドレスのデータを読んでしまいます。何も警告してくれません。ロードが同じ命令内で値を返すマイクロコントローラの感覚から頭を切り替えるには、少し慣れが要ります。通常のパターンはパイプライン化です。データを消費する側も1サイクル後にレジスタで受けるようにして、レイテンシを吸収します。
リードビフォアライトとライトファースト
weがHighで、書き込み中のアドレスが読み出し中のアドレスと同じサイクルに、rdataは何を示すでしょうか。上のモジュールでは、答えは古い内容です。どちらのノンブロッキング代入も、エッジ前のmem[addr]を読むからです。これをリードビフォアライト、またはリードファーストと呼び、ほとんどのツールがこのコードから推論する動作であり、ハードウェアのデフォルトの動作でもあります。
ライトファーストとは、rdataが書き込み中のデータを示すことです。次のように書きます。
// Single-port RAM, write-first: on a write cycle rdata shows the new data.
module ram_sp_wf #(
parameter integer DW = 8,
parameter integer AW = 10
) (
input wire clk,
input wire we,
input wire [AW-1:0] addr,
input wire [DW-1:0] wdata,
output reg [DW-1:0] rdata
);
reg [DW-1:0] mem [0:(1 << AW) - 1];
always @(posedge clk) begin
if (we) begin
mem[addr] <= wdata;
rdata <= wdata;
end else
rdata <= mem[addr];
end
endmodule
第3のモードとしてノーチェンジがあり、書き込み中はrdataが前の値を保持します。3つすべてがAMDのブロックRAMにハードウェアモードとして存在し、合成ツールはコードに一致するものを選びます。避けるべきは、どれにも一致しない動作のコードを書くことです。たとえば、デュアルポートメモリの一方のポートでライトファースト読み出しをしながら、もう一方のポートが同じアドレスに書き込む場合です。ツールはそのケースを保証できず、そう言ってきます。
シンプルデュアルポートRAM
実際の用途のほとんどは、ある場所から書き込み、同時に別の場所から読み出す必要があります。シンプルデュアルポートRAMは書き込みポート1つと読み出しポート1つを持ち、それぞれが独自のアドレスを持ちます。これがFIFOの土台となるメモリであり、フレームバッファやサンプルバッファでも一般的なケースです。
// Simple dual-port RAM: one write port, one independent read port.
module ram_sdp #(
parameter integer DW = 8,
parameter integer AW = 10
) (
input wire clk,
input wire we,
input wire [AW-1:0] waddr,
input wire [DW-1:0] wdata,
input wire [AW-1:0] raddr,
output reg [DW-1:0] rdata
);
reg [DW-1:0] mem [0:(1 << AW) - 1];
always @(posedge clk) begin
if (we)
mem[waddr] <= wdata;
rdata <= mem[raddr];
end
endmodule
VHDLで同じものです。配列型を明示的に宣言し、アドレスを整数のインデックスに変換します。
-- Simple dual-port RAM that maps to block RAM.
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity ram_sdp is
generic (
DW : positive := 8;
AW : positive := 10
);
port (
clk : in std_logic;
we : in std_logic;
waddr : in std_logic_vector(AW - 1 downto 0);
wdata : in std_logic_vector(DW - 1 downto 0);
raddr : in std_logic_vector(AW - 1 downto 0);
rdata : out std_logic_vector(DW - 1 downto 0)
);
end entity;
architecture rtl of ram_sdp is
type mem_t is array (0 to 2 ** AW - 1) of std_logic_vector(DW - 1 downto 0);
signal mem : mem_t := (others => (others => '0'));
begin
process (clk)
begin
if rising_edge(clk) then
if we = '1' then
mem(to_integer(unsigned(waddr))) <= wdata;
end if;
rdata <= mem(to_integer(unsigned(raddr)));
end if;
end process;
end architecture;
どちらの版も、同じクロック上に書き込みポート1つと読み出しポート1つを持ちます。2つのポートがそれぞれ読み書きできる真のデュアルポート(true dual-port)RAMは、同じ配列に対するこのようなプロセス2つとして書き、ツールは同じ方法で推論します。2つのポートに異なるクロックを与えることも許されており、非同期FIFOでデータがクロック領域を渡る仕組みがこれです。これは中級トラックの主題です。
合成後に使用率レポートを見てください。メモリがブロックRAMに収まっていれば、そこに数えられています。代わりに数百LUTの分散RAMになっていたら、コードの何かがパターンを壊しています。最も多いのは出力レジスタのリセット、非同期読み出し、ファミリが対応していないバイトマスク付きの書き込みです。
hexファイルからのROM
ROMは決して書き込まないRAMです。エラボレーション時に内容を埋めると、合成ツールはその内容をブロックRAMの初期化データに入れ、コンフィギュレーション時にビットストリームがロードします。Verilogの$readmemhは、1行に1つの16進値を並べたテキストファイルを読みます。
// 256-entry ROM loaded from a hex file at elaboration time.
module rom_sin (
input wire clk,
input wire [7:0] addr,
output reg [7:0] data
);
reg [7:0] mem [0:255];
initial $readmemh("sin_table.hex", mem);
always @(posedge clk)
data <= mem[addr];
endmodule
ファイルはプレーンテキストなので、正弦波テーブルの先頭数行は次のようになります。
80
83
86
89
$readmemhはシミュレーション用の構文ですが、Vivado、Quartus Prime、Yosysはいずれも合成で尊重し、本稿執筆時点ではGowin EDAも同様です。そのため、メモリを初期化する最も移植性の高い方法になっています。注意点が2つあります。パスはソースファイルではなくツールの作業ディレクトリからの相対で解決されるため、あるマシンで動くビルドが別のマシンでは失敗することがあります。そして、このように使うinitialブロックは、「initialにはハードウェア上の意味がない」という規則の唯一の例外です。同じ手法、つまりinitialで定数を代入することが、ほとんどのFPGAファミリでレジスタが電源投入時の値を得る方法です。
VHDLでの同等物は、textioでファイルを読む関数によって作られる定数配列か、ソース内に値を書き出した定数です。
同期FIFO
FIFOはキューです。一方の側が語を書き込み、もう一方が同じ順序で読み出し、両側はタイミングについて合意する必要がありません。UART受信器がプロセッサにバイトを渡す方法、ピクセルパイプラインが遅いステージを許容する方法、DMAエンジンがバーストをバッファする方法がこれです。同期FIFOは両側が同じクロック上にあります。非同期FIFOはそうではなく、より難しくなります。
設計は、デュアルポートRAMと2つのカウンタです。書き込みポインタは次の語がどこへ行くかを、読み出しポインタは次の語がどこから来るかを示し、どちらもメモリの末尾で折り返します。emptyとはポインタが等しいことです。しかし、書き込みポインタが読み出しポインタを1周追い越した後もポインタは等しくなるので、fullもまたポインタが等しいことを意味します。この曖昧さこそが、FIFOにおける唯一の設計問題です。きれいな解決策は、各ポインタをアドレスより1ビット広くすることです。下位ビットがRAMをアドレスします。すべてのビットが一致すればFIFOは空です。下位ビットは一致するが最上位ビットが異なれば、書き込み側が読み出し側よりちょうど1周多く回ったことになり、FIFOは満杯です。
// Synchronous FIFO built on a simple dual-port RAM.
// DEPTH must be a power of two.
module fifo_sync #(
parameter integer DW = 8,
parameter integer DEPTH = 16
) (
input wire clk,
input wire rst,
input wire wr_en,
input wire [DW-1:0] wdata,
input wire rd_en,
output wire [DW-1:0] rdata,
output wire full,
output wire empty
);
localparam integer AW = $clog2(DEPTH);
// Pointers carry one extra bit so full and empty can be told apart.
reg [AW:0] wptr, rptr;
wire do_write = wr_en && !full;
wire do_read = rd_en && !empty;
ram_sdp #(.DW(DW), .AW(AW)) u_mem (
.clk(clk), .we(do_write),
.waddr(wptr[AW-1:0]), .wdata(wdata),
.raddr(rptr[AW-1:0]), .rdata(rdata)
);
always @(posedge clk) begin
if (rst) begin
wptr <= 0;
rptr <= 0;
end else begin
if (do_write) wptr <= wptr + 1'b1;
if (do_read) rptr <= rptr + 1'b1;
end
end
assign empty = (wptr == rptr);
assign full = (wptr[AW] != rptr[AW]) && (wptr[AW-1:0] == rptr[AW-1:0]);
endmodule
full時の書き込みとempty時の読み出しは、ポインタを壊すのではなく無視されます。これがFIFOの利用者が頼れる動作です。RAMの読み出しはレジスタ付きなので、rdataはrd_enが受理された次のサイクルに有効になります。FIFOの利用者の多くは、ファーストワードフォールスルー(first-word-fall-through)のインターフェースを好みます。キューの先頭が常にrdataに見えていて、rd_enは確認応答として働くものです。それはこのFIFOの小さなラッパーであり、演習になっています。
テストベンチは、深さ4のFIFOに5回の書き込みで満たし、fullが立って5回目の書き込みが拒否されたことを検査し、次に空にして4語が順番どおり戻ってくることとemptyが復帰することを検査します。
`timescale 1ns / 1ps
module tb_fifo_sync;
reg clk = 0, rst = 1, wr_en = 0, rd_en = 0;
reg [7:0] wdata = 0;
wire [7:0] rdata;
wire full, empty;
fifo_sync #(.DW(8), .DEPTH(4)) dut (
.clk(clk), .rst(rst), .wr_en(wr_en), .wdata(wdata),
.rd_en(rd_en), .rdata(rdata), .full(full), .empty(empty));
always #5 clk = ~clk;
integer i, errors = 0;
initial begin
$timeformat(-9, 0, " ns", 6);
$dumpfile("fifo_sync.vcd");
$dumpvars(0, tb_fifo_sync);
repeat (2) @(posedge clk);
rst <= 0;
// Fill it. The fifth write must be refused.
for (i = 0; i < 5; i = i + 1) begin
@(posedge clk);
wr_en <= 1; wdata <= 8'hA0 + i;
end
@(posedge clk); wr_en <= 0;
@(negedge clk);
if (!full) begin errors = errors + 1; $display("FAIL: not full"); end
// Drain it. rdata is valid the cycle after rd_en, because the RAM
// read is registered.
for (i = 0; i < 4; i = i + 1) begin
@(posedge clk); rd_en <= 1;
@(posedge clk); rd_en <= 0;
@(negedge clk);
if (rdata !== 8'hA0 + i) begin
errors = errors + 1;
$display("FAIL: read %h, expected %h", rdata, 8'hA0 + i);
end else
$display("t=%0t read %h", $time, rdata);
end
@(negedge clk);
if (!empty) begin errors = errors + 1; $display("FAIL: not empty"); end
if (errors == 0) $display("PASS"); else $display("FAIL: %0d errors", errors);
$finish;
end
endmodule
iverilog -g2012 -o fifo.vvp ram_sdp.v fifo_sync.v tb_fifo_sync.v
vvp fifo.vvp
検査は立ち下がりエッジ、つまり出力が整定してから半サイクル後に行うので、テストベンチが設計とレースすることはありません。a0からa3を示す4行のreadと、続くPASSが出るはずです。
演習
fifo_syncをファーストワードフォールスルーFIFOで包んでください。ラッパーは、emptyがLowのときは常に最も古い語をrdataに提示し、rd_enを「受け取った」という意味として扱います。最も簡単な方法は、1語の出力レジスタを持ち、それが空か受け取られたときはいつでも内側のFIFOから再ロードすることです。テストベンチを拡張して、rd_enをHighに保ったまま連続して読み出し、隙間なく1サイクルに1語が届くことを検査してください。次に、ram_sdpを単体で、AWを4にして合成し、さらに12にして合成し、それぞれの場合にツールがメモリをどこに置いたかを比較してください。
まとめ
- 分散RAMはLUTから作られ、組み合わせ的に読み出せます。ブロックRAMは専用で、大きく、常に1サイクルのレジスタ付き読み出しを持ちます。
- メモリは、イネーブルの下でのノンブロッキング書き込みと、無条件のレジスタ付き読み出しを持つ配列として書きます。このパターンは主要なすべてのツールでブロックRAMを推論します。
- リードビフォアライトは書き込みサイクルに古いデータを返し、ライトファーストは新しいデータを返します。意図した方を書き、デュアルポートメモリでのポート衝突を避けてください。
initialブロック内の$readmemhは、シミュレータと合成ツールをまたいで移植性のある方法でROMやRAMを初期化します。- FIFOはデュアルポートRAMと2つのポインタです。fullとemptyを区別するために、ポインタをアドレスより1ビット広くします。
さらに読む
- AMD UG473、7 Series FPGAs Memory Resources。推論パターンの背後にあるハードウェア
- AMD UG901、Vivado Design Suite User Guide: Synthesis。RAMのHDLコーディング手法の章
- Yosysマニュアル、メモリの扱い。オープンソースフローがメモリをどうマッピングするかについて
- Gowinドキュメントセンター。GW1NブロックSRAMユーザーガイドについて