はじめに
UART (Universal Asynchronous Receiver Transmitter) はシリアル通信の方式の一つです[1][2][3].FPGA から PC にログを送ったり[3],デバイスにデータを送ったりすることができます[4].UART はシリアル通信の方式としては単純なものであるため,様々なデバイスで使われています.
この記事では FPGA ボードで UART を使う方法を簡潔に解説します.UART が初めての方でも理解できるように,まず UART の仕組みについて説明します.そして FPGA 上に UART で通信するための回路を実装し,PC と FPGA ボード間でテキスト通信する方法を紹介します.
この記事を通して,UART を使って FPGA ボードで遊んでみましょう!
使用する FPGA ボード
今回使用する FPGA ボードは Basys 3 [5] です.Basys3 は Digilent 社が販売している FPGA ボードです.FPGA そのものはリソース量が少ないものの,ペリフェラルが沢山備わっています.そしてなにより AMD 社のチップが乗ったボードの中でもかなり格安なので,ディジタル回路の入門に良さそうです.
UART の仕組み
ハードウェアの構成
Basys 3 は USB-UART Bridge を搭載しています[6].FPGA は,この USB-UART Bridge を介して,外部のデバイスと UART の通信を行います.Basys 3 と PC を USB ケーブルで接続することで,PC と FPGA 間で様々なデータをやりとりすることができます.
通信プロトコル
UART は調歩同期式のプロトコルで通信します[2].このプロトコルは以下の4種類のビットでデータを通信します.各ビットはスタートビットから順番に送信されます.
以降,この記事ではパリティビットなし(0ビット),ストップビット1ビットとします.
例えばデータとして 0xA5 (=0b10100101) を送信する場合,送信するビット列は,スタートビットとストップビットを含めた,"0101001011" です(LSB から順に送信するため,データビットの順番を反転していることに注意).
ビットの種類 | 一般的なサイズ(ビット) | 電圧レベル | 備考 |
---|---|---|---|
スタートビット | 1 | ロー | |
データビット | 8 | 1/0 => ハイ/ロー | LSB から順に送信される |
パリティビット | 0 または 1 | - | |
ストップビット | 1 または 2 | ハイ |
これらのビットの一般的な通信速度1は 9600 や 115200 bps などです.この場合における各ビットの送信周期はそれぞれ約 10416ns と約 868ns です.通信速度は PC 環境やデバイスによって扱えるものとそうでないものがありますので,事前に調べておきましょう.
以降,この記事では通信速度を 115200 bps とします.
先ほど挙げた 0xA5 の送信では,以下のタイミングで信号を駆動します.最初の0がスタートビットで,続く8ビットがデータビット,最後の1がストップビットです.
UART モジュールの開発
この節では UART で通信するためのモジュールを実装します.このモジュールは,USB-UART Bridge との信号の他に,AMD の FIFO バッファ IP とのインターフェースを持たせることにします.実装に使用する言語は SystemVerilog です.
送信モジュール
設計
送信モジュールは前節で述べたタイミングでビット列を送信します.送信モジュールの状態遷移図を以下に記載します.
初めに STATE_WAIT 状態の送信モジュールは,入力 FIFO バッファ の empty 信号がローになったのをトリガとして,STATE_ASSERT_READ_ENABLE 状態に遷移します.ここでは,バッファにデータが入力されたのを検出し,バッファに対して re (read enable) 信号をアサートします.
続いて STATE_READ_WORD 状態に遷移します.先程 re 信号をアサートしたことで,入力データをバッファから読み出せるようになりました.送信する入力データを読み出します.
最後に STATE_TRANSMIT_BITS 状態に遷移します.ここではスタートビット,データビット,ストップビットを順に送信します.
以上のビット列が送信完了したら STATE_WAIT 状態に戻ります.再び新たな入力データを送信することが可能です.
実装
送信モジュールの実装を以下に記載します.このモジュールは data
レジスタにスタートビット,データビット及びストップビットを格納し,1ビットずつ送信します.
実装を簡単にするために,data
レジスタの扱いを工夫しました.このレジスタはビットの送信を完了する度に内容を1ビット右シフトし,MSB を 0 埋めします.これにより,ストップビット送信時にのみレジスタの内容が 0b0000000001
となります.よって何ビット目を送信中なのかをカウントする必要がありません.
module transmitter #(
parameter [31:0] CLOCK_FREQUENCY = 32'd100_000_000,
parameter [31:0] BAUD_RATE = 32'd115200,
parameter [31:0] WORD_WIDTH = 32'd8
) (
input clk,
input rst,
input [WORD_WIDTH-1:0] din,
input empty,
output re,
output dout
);
// constant
localparam [31:0] CLOCKS_PER_BIT = CLOCK_FREQUENCY / BAUD_RATE;
// type definition
typedef enum logic [1:0] {
STATE_WAIT = 2'h0,
STATE_ASSERT_READ_ENABLE = 2'h1,
STATE_READ_WORD = 2'h2,
STATE_TRANSMIT_BITS = 2'h3
} state_t;
// registers and wires
state_t state;
logic [WORD_WIDTH+1:0] data;
logic [31:0] clock_counts;
logic full_clock_counts;
// logics
assign re = (state == STATE_ASSERT_READ_ENABLE);
assign dout = (state == STATE_TRANSMIT_BITS) ? data[0] : 1'b1;
always_ff@(posedge clk)
if (rst)
state <= STATE_WAIT;
else
case (state)
STATE_WAIT: state <= (~empty) ? STATE_ASSERT_READ_ENABLE : state;
STATE_ASSERT_READ_ENABLE: state <= STATE_READ_WORD;
STATE_READ_WORD: state <= STATE_TRANSMIT_BITS;
STATE_TRANSMIT_BITS: state <= (full_clock_counts && (data == {{WORD_WIDTH{1'b0}}, 1'b1})) ? STATE_WAIT : state;
endcase
always_ff@(posedge clk)
if (rst)
data <= {WORD_WIDTH+1{1'b1}};
else
case (state)
STATE_READ_WORD: data <= {1'b1, din, 1'b0};
STATE_TRANSMIT_BITS: data <= full_clock_counts ? {1'b0, data[WORD_WIDTH+1:1]} : data;
default: data <= {WORD_WIDTH+1{1'b1}};
endcase
always_ff@(posedge clk)
if (rst)
clock_counts <= 32'h0;
else
case (state)
STATE_TRANSMIT_BITS: clock_counts <= full_clock_counts ? 32'h0 : clock_counts + 32'h1;
default: clock_counts <= 32'h0;
endcase
assign full_clock_counts = (clock_counts == (CLOCKS_PER_BIT - 1));
endmodule
受信モジュール
設計
受信モジュールも前節で述べたタイミングでビット列を受信します.受信モジュールの状態遷移図を以下に記載します.
初めに STATE_WAIT 状態の送信モジュールは,入力信号がローになったのをトリガとして,STATE_RECEIVE_BITS 状態に遷移します.ただし,遷移後にスタートビットを検出できなかった場合,STATE_WAIT 状態に戻ります.スタートビットを検出できた場合,続くデータビットとストップビットを受信します.
ストップビットを検出できた場合,STATE_WRITE_WORD 状態に遷移します.この状態では,出力 FIFO バッファに受信データを書き込みます.その後 STATE_WAIT 状態に戻ります.
ストップビットを検出できなかった場合,STATE_WAIT 状態に遷移します.つまりバッファに受信データを書き込みません.
STATE_WAIT 状態に戻ると,再び新たな入力データを送信することが可能です.
「STATE_WAIT 状態で入力信号がローになったらスタートビットを検出できているのに,どうして STATE_RECEIVE_BITS 状態でスタートビットを検出するのだろう」と思った方がいるかもしれません.これは実装の都合です.今回実装するモジュールは,スタートビットの立ち下がりよりも半周期遅れた時点から,毎周期信号をサンプリングします.STATE_RECEIVE_BITS 状態に遷移した時点でスタートビットが受信済みとする実装は,かえって複雑になりそうです.そのためこのような設計にしています.
もちろんこの設計が最良とは思いません.ぜひ他の方の実装も調べてみて下さい.
実装
受信モジュールの実装を以下に記載します.このモジュールは入力データを data
レジスタに格納し,FIFO バッファに書き込みます.
送信モジュールと同じく,実装を簡単にするために data
レジスタの扱いを工夫しました.このレジスタはデータビットだけでなく,スタートビットとストップビットも格納します.また初期値は 10'b1111111111 です.よって MSB が 1 かつ LSB が 0 の場合,通信が期待通りに完了したことが分かります.MSB と LSB がそれぞれ 0 の場合,ストップビットの受信に失敗したことが分かります.その場合は受信データをバッファに書き込みません.
module receiver #(
parameter [31:0] CLOCK_FREQUENCY = 32'd100_000_000,
parameter [31:0] BAUD_RATE = 32'd115200,
parameter [31:0] WORD_WIDTH = 32'd8
) (
input clk,
input rst,
input din,
output [WORD_WIDTH-1:0] dout,
input full,
output we
);
// constants
localparam [31:0] CLOCKS_PER_BIT = CLOCK_FREQUENCY / BAUD_RATE;
// type definition
typedef enum logic [1:0] {
STATE_WAIT = 2'h0,
STATE_RECEIVE_BITS = 2'h1,
STATE_WRITE_WORD = 2'h2
} state_t;
// registers and wires
state_t state;
logic [WORD_WIDTH+1:0] data;
logic [31:0] clock_counts;
logic half_clock_counts;
logic full_clock_counts;
// logics
assign dout = data[WORD_WIDTH:1];
assign we = (state == STATE_WRITE_WORD);
always_ff@(posedge clk)
if (rst)
state <= STATE_WAIT;
else
case (state)
STATE_WAIT: state <= (~din) ? STATE_RECEIVE_BITS : state;
STATE_RECEIVE_BITS:
if (full_clock_counts) begin
if (~data[0])
state <= (data[WORD_WIDTH+1] && (~full)) ? STATE_WRITE_WORD : STATE_WAIT;
else
state <= (data == {{WORD_WIDTH+2}{1'b1}}) ? STATE_WAIT : state;
end
else
state <= state;
STATE_WRITE_WORD: state <= STATE_WAIT;
default: state <= state;
endcase
always_ff@(posedge clk)
if (rst)
data <= {{WORD_WIDTH+2}{1'b1}};
else
case (state)
STATE_RECEIVE_BITS: data <= half_clock_counts ? {din, data[WORD_WIDTH+1:1]} : data;
STATE_WRITE_WORD: data <= data;
default: data <= {{WORD_WIDTH+2}{1'b1}};
endcase
always_ff@(posedge clk)
if (rst)
clock_counts <= 32'h0;
else
case (state)
STATE_RECEIVE_BITS: clock_counts <= full_clock_counts ? 32'h0 : clock_counts + 32'h1;
default: clock_counts <= 32'h0;
endcase
assign half_clock_counts = (clock_counts == (CLOCKS_PER_BIT / 2));
assign full_clock_counts = (clock_counts == (CLOCKS_PER_BIT - 1));
endmodule
応用
前節の受信モジュールと送信モジュールを応用し,実際にシリアル通信をしてみます.まず簡単な応用例としてループバックを実装します.次にもう少しだけステップアップして,シーザー暗号を実装します.
ループバック
ループバックは受信したデータを送信します.つまり,PC からは送信したデータがそのまま帰ってきたようにみえます.
設計
ループバックのブロック図を以下に記載します.入力と出力はそれぞれ UART の入出力信号です.受信モジュールは入力信号から入力データを受信し,FIFO バッファへ書き込みます.送信モジュールは FIFO バッファのデータを送信信号として出力します.
FIFO バッファのおかげで,受信モジュールは送信モジュールの状態を気にすることなくデータを出力できます.つまり,送信モジュールが待機中か送信中かに関わらず,受信モジュールはデータを出力できるわけです.
もし両方のモジュールが直接接続される設計である場合,受信モジュールは送信モジュールがデータを受け取るまで待たされることになります.その間に次々と入力データが届いたとしたら,受信モジュールは入力データを取り零してしまいます.
ただし,今回の設計では FIFO バッファが無くても前述した入力データの取り零しは発生し得ません.入出力信号の通信速度が同じであり,かつ両方のモジュールの間には FIFO バッファしかないためです.もし両方のモジュールの間に何かしらのモジュールがあり,そのモジュールの処理が一定程度の時間を要する場合は,入力データの取り零しが発生しそうです.
本稿では今後の拡張性を考慮し,FIFO バッファを介する設計を採用しています.受信モジュールと送信モジュールの間に別のモジュールがある設計は,次のシーザー暗号化でチャレンジします.
実装
ループバックの実装を以下に記載します.送信モジュールと受信モジュールは FIFO バッファを介しています.受信したデータは変えられることなく送信されます.
なおこの FIFO バッファは AMD の IP ですので,この IP が使えるように Vivado で設定が必要です.次の節で設定を含む開発手順を説明します.
module loopback (
input clk,
input rst,
input rxd,
output txd
);
logic [7:0] received_word;
logic full;
logic we;
logic [7:0] word_to_transmit;
logic empty;
logic re;
receiver rx (
.clk(clk),
.rst(rst),
.din(rxd),
.dout(received_word),
.full(full),
.we(we)
);
fifo_buffer fb (
.clk(clk),
.srst(rst),
.din(received_word),
.full(full),
.wr_en(we),
.dout(word_to_transmit),
.empty(empty),
.rd_en(re)
);
transmitter tx (
.clk(clk),
.rst(rst),
.din(word_to_transmit),
.empty(empty),
.re(re),
.dout(txd)
);
endmodule
Vivado による開発手順
Vivado でループバック回路を開発します.まず以下の構成のディレクトリを作成してください.
$ tree
.
└── src
├── loopback.sv
├── receiver.sv
└── transmitter.sv
1 directory, 3 files
続いて Vivado を起動し,このディレクトリにループバックのプロジェクトを作成します.Create Project をクリックして下さい.
Next をクリックします.
Project name は loopback,Project location は先程の src ディレクトリの位置とします.
Project type は RTL Project とします."Do not specify source at this time" にチェックを入れます."Project is an extensible Vitis platform" のチェックは外したままにしておきます.
Default Part を選択します."Parts | Boards" は Boards を選択し,Vendor は digilentinc.com を選択します.Basys3 の行を選択し,Next を選択します.
以下の内容のポップアップが表示されたら,Finish をクリックして下さい.
src ディレクトリにある SystemVerilog ファイルをプロジェクトに追加します.赤枠で囲われた Add Sources をクリックします.
次のポップアップが表示されます."Add or create design sources" を選択し,Next をクリックして下さい.
Add Files をクリックし,src ディレクトリのファイルを全て選択して下さい.ポップアップ上に追加したファイル名が表示されます.
FIFO バッファ IP を使えるように設定します.赤枠で囲われた IP Catalog から,FIFO Generator を検索して下さい.
FIFO Generator をダブルクリックすると,ポップアップが表示されます.Component Name を fifo_buffer として下さい. Basic タブでは,Interface Type を Native,他はデフォルトの状態にして下さい.
続いて Native Ports タブに切り替え,Data Port Parameters の Write Width を 8,Write Depth を 16,他はデフォルトの状態にして下さい.
OK を選択した後,次のポップアップが表示されます.Generate を選択して下さい."Out-of-context module run was launched for generating output products." というポップアップが表示されたら成功です.OK を選択してポップアップを閉じて下さい.
制約ファイルをプロジェクトに追加します.ソースコードを追加したときと同じように,Add Sources から制約ファイルを生成します.
Create File をクリックし,constrains.xdc を生成します.
Sources から Constraints を確認して下さい.生成したファイルを開けるようになりました.
制約ファイルを記述します.ファイルの内容は以下のとおりです.トップモジュールの入出力信号と FPGA のピンを対応させています.
## Clock signal
set_property -dict { PACKAGE_PIN W5 IOSTANDARD LVCMOS33 } [get_ports clk]
create_clock -add -name sys_clk_pin -period 10.00 -waveform {0 5} [get_ports clk]
##Buttons
set_property -dict { PACKAGE_PIN U18 IOSTANDARD LVCMOS33 } [get_ports rst]
##USB-RS232 Interface
set_property -dict { PACKAGE_PIN B18 IOSTANDARD LVCMOS33 } [get_ports rxd]
set_property -dict { PACKAGE_PIN A18 IOSTANDARD LVCMOS33 } [get_ports txd]
## Configuration options, can be used for all designs
set_property CONFIG_VOLTAGE 3.3 [current_design]
set_property CFGBVS VCCO [current_design]
## SPI configuration mode options for QSPI boot, can be used for all designs
set_property BITSTREAM.GENERAL.COMPRESS TRUE [current_design]
set_property BITSTREAM.CONFIG.CONFIGRATE 33 [current_design]
set_property CONFIG_MODE SPIx4 [current_design]
最後にウィンドウ左側の Generate Bitstream
をクリックして bitstream を生成します.以下のポップアップが表示されたら成功です.Open Hardware Manager を選択し,OK をクリックします.
PC と Basys 3 を USB ケーブルで接続して,bitstream を書き込みましょう.Program device をクリックします.
Program をクリックすると,bitstream が FPGA に書き込まれます.
動作確認
実際に PC と Basys 3 の間でシリアル通信してみます.
今回は Ubuntu マシンにインストールした screen
コマンドでテキストを送受信してみます.以下はターミナルへの入力です.私の環境では実行に管理者権限が必要でした.またシリアル通信のためのデバイスファイルは /dev/ttyUSB1
でした.デバイスファイルは環境によって名前が異なりますので注意して下さい.最後の 115200 は通信速度です.
$ sudo screen /dev/ttyUSB1 115200
以下はテキスト通信の様子です.キーボードで入力したテキストがターミナルに表示されています.screen コマンドで表示されるテキストは,受信したテキストです.つまり,PC から送信したテキストが FPGA に届き,FPGA からそのテキストが PC に届いたことが分かります.
シーザー暗号化
シーザー暗号は古典的な暗号です.アルファベットのテキストを3文字シフトして暗号文とする暗号です.例えば 'apple' は暗号化すると 'dssoh' です.実社会で使われている暗号と比較すると実用には耐えませんが,遊びで使う分には十分です.
設計
シーザー暗号化のブロック図を以下に記載します.基本的な構成はループバックと同じです.異なる点は,シーザー暗号化カーネルが追加されたことと,FIFO バッファを1つ増やしたことです.
シーザー暗号化カーネルは,FIFO バッファ0から受信データを読み出します.このデータを3文字シフトした後,FIFO バッファ1に書き込みます.
実装
シーザー暗号化回路の実装を以下に記載します.ループバック回路の実装に,暗号化モジュールともう一つの FIFO バッファを追加しています.
module top (
input clk,
input rst,
input rxd,
output txd
);
logic [7:0] received_word;
logic full0;
logic we0;
logic [7:0] word_to_encrypt;
logic empty0;
logic re0;
logic [7:0] encrypted_word;
logic full1;
logic we1;
logic [7:0] word_to_transmit;
logic empty1;
logic re1;
receiver rx (
.clk(clk),
.rst(rst),
.din(rxd),
.dout(received_word),
.full(full0),
.we(we0)
);
fifo_buffer fb0 (
.clk(clk),
.srst(rst),
.din(received_word),
.full(full0),
.wr_en(we0),
.dout(word_to_encrypt),
.empty(empty0),
.rd_en(re0)
);
caesar_encryption kernel (
.clk(clk),
.rst(rst),
.din(word_to_encrypt),
.empty(empty0),
.re(re0),
.dout(encrypted_word),
.full(full1),
.we(we1)
);
fifo_buffer fb1 (
.clk(clk),
.srst(rst),
.din(encrypted_word),
.full(full1),
.wr_en(we1),
.dout(word_to_transmit),
.empty(empty1),
.rd_en(re1)
);
transmitter tx (
.clk(clk),
.rst(rst),
.din(word_to_transmit),
.empty(empty1),
.re(re1),
.dout(txd)
);
endmodule
暗号化モジュールを以下に記載します.入力 FIFO バッファにデータがあれば読み出し,3文字シフトして出力 FIFO バッファに書き出します.なおアルファベットでない文字が入力された場合は,入力文字をそのまま出力することにしました.
module caesar_encryption (
input clk,
input rst,
input [7:0] din,
input empty,
output re,
output logic [7:0] dout,
input full,
output we
);
// type definition
typedef enum logic [1:0] {
STATE_WAIT = 2'h0,
STATE_ASSERT_READ_ENABLE = 2'h1,
STATE_READ_WORD = 2'h2,
STATE_WRITE_WORD = 2'h3
} state_t;
// registers and wires
state_t state;
logic [7:0] data;
// logics
assign re = (state == STATE_ASSERT_READ_ENABLE);
assign we = (state == STATE_WRITE_WORD);
always_comb
if ((8'h41 <= data) && (data <= 8'h57)) /* 'A' - 'W' */
dout = data + 8'h3;
else if ((8'h58 <= data) && (data <= 8'h5A)) /* 'X' - 'Z' */
dout = data - 8'h17;
else if ((8'h61 <= data) && (data <= 8'h77)) /* 'a' - 'w' */
dout = data + 8'h3;
else if ((8'h78 <= data) && (data <= 8'h7A)) /* 'x' - 'z' */
dout = data - 8'h17;
else /* otherwise */
dout = data;
always_ff@(posedge clk)
if (rst)
state <= STATE_WAIT;
else
case (state)
STATE_WAIT: state <= (~empty) ? STATE_ASSERT_READ_ENABLE : state;
STATE_ASSERT_READ_ENABLE: state <= STATE_READ_WORD;
STATE_READ_WORD: state <= STATE_WRITE_WORD;
STATE_WRITE_WORD: state <= STATE_WAIT;
endcase
always_ff@(posedge clk)
if (rst)
data <= " ";
else
case (state)
STATE_READ_WORD: data <= din;
default: data <= data;
endcase
endmodule
Vivado による開発手順
ループバック回路の開発と共通する手順については省略します.
Vivado でシーザー暗号化回路を開発します.まず以下の構成のディレクトリを作成してください.
$ tree
.
└── src
├── caesar_encryption.sv
├── receiver.sv
├── top.sv
└── transmitter.sv
1 directory, 4 files
src ディレクトリにある SystemVerilog ファイルをプロジェクトに追加します.
bitstream が作成されたら,FPGA に書き込みましょう.
動作確認
PC と Basys 3 の間でシリアル通信してみます.ループバック回路と共通する手順については省略します.
以下は暗号化の様子です.キーボードで入力したテキストが暗号化されてターミナルに表示されています.2
おわりに
この記事では FPGA ボードで UART を使う方法について解説しました.
はじめに UART の仕組みについて説明しました.Basys 3 ボードの USB-UART Bridge を介した UART 通信について述べ,UART でやりとりされる調歩同期式の信号がどのようなものであるか述べました.
次に FPGA で UART を使うための方法を説明しました.状態遷移図と SystemVerilog コードを挙げて,送受信するための回路について述べました.
最後に送受信モジュールを使用しテキスト通信する回路を開発しました.ループバックとシーザー暗号化の二種類の回路を作成し,送受信モジュールの応用方法を例示しました.
この記事を読んだみなさんが,実際に UART を使って FPGA ボードで遊んでくださるのを期待しております.
FPGA ボードでまだまだ遊びたい方は,以前の投稿もご覧下さい!
使用した開発環境
- OS: Ubuntu 22.04 LTS
- CPU: AMD Ryzen 7 3700X
- Vivado 2022.2
参考文献
[1]: AnalogDialogue, "UART――多様な非同期通信に対応可能なハードウェア通信プロトコル", https://www.analog.com/jp/analog-dialogue/articles/uart-a-hardware-communication-protocol.html.
[2]: ローム株式会社, "UART", https://www.rohm.co.jp/electronics-basics/micon/mi_what9.
[3]: ACRi, "シリアル通信で Hello, FPGA (1)", https://www.acri.c.titech.ac.jp/wordpress/archives/123.
[4]: Digilent, "Basys 3 General I/O Demo", https://digilent.com/reference/programmable-logic/basys-3/demos/gpio.
[5]: elchika, "Raspberry Pi の UART で MIDI 送信", https://elchika.com/article/97a7905a-4144-4c4a-a68d-527a0827bd96/.
[6]: AMD Xilinx, "Digilent Basys 3 Artix-7 FPGA Board," https://japan.xilinx.com/products/boards-and-kits/1-54wqge.html.
[7]: Digilent, "Basys 3 Reference Manual", https://digilent.com/reference/programmable-logic/basys-3/reference-manual#usb-uart_bridge_serial_port.
注釈
-
この通信速度はボーレートと呼ばれることもあります.ただし,実際は通信速度とボーレートは違う概念です. ↩
-
先日 Twitter でシーザー暗号がトレント入りしました(https://www.inside-games.jp/article/2023/08/03/147608.html).そのとき暗号化されたと思しき元のテキストを入力しています.まあシフト数が違うので,結果は全く違うのですが. ↩