Basys 3でUARTエコー
115200ボーのUART受信器と送信器をVerilogで作り、入力した文字を大文字・小文字を反転して返す回路をシミュレーションしてから、シリアルターミナルで実機テストします。
| ボード | Digilent Basys 3 |
|---|---|
| 所要時間 | 約3時間 |
| 言語 | verilog |
| 追加部品 | Digilent Basys 3、Micro-USBケーブル、Vivadoとシリアルターミナルソフトを入れたパソコン |
作るもの
このプロジェクトでは、Basys 3のFPGAの中にシリアルポートを作ります。UART受信器と送信器をVerilogで書き、両者をつないでターミナルから入力したバイトをそのまま返すようにします。さらにひとひねり加えて、英字は大文字と小文字を反転して返します。Fpgaと打つとfPGAが表示されるわけです。このひねりには意味があります。単純なループバックなら2本の線を短絡するだけで偽装できますが、大小文字の反転はバイトが自分のロジックを通過した証拠になるからです。
途中で、自己チェック式のテストベンチ、制約ファイル、そしてGUIとTclの両方によるVivadoビルドを書きます。入門トラックのモジュールとステートマシンのレッスンを終えた後の最初のプロジェクトに適しており、後のプロジェクトで再利用できるUARTのペアが手に入ります。
必要なもの
- Digilent Basys 3(Artix 7 XC7A35T)とそのMicro-USBケーブル。1つのUSBポートがJTAGプログラマとUSB-UARTブリッジの両方を兼ねるので、2本目のケーブルは不要です。
- AMD Vivado(無償のStandardエディション)。この設計には2022以降のどのリリースでも使えます。本稿執筆時点では2025.1が最新です。
- シリアルターミナル。LinuxとmacOSでは
screenまたはminicom、WindowsではPuTTYかWindows Terminalのシリアルプロファイル。 - 任意で、Vivadoの外でシミュレーションとリントを行うためのIcarus VerilogとVerilator。Vivadoのシミュレータでも構いません。
設計
115,200ボーのUARTフレームは、スタートビット(Low)、最下位ビットから順に送る8ビットのデータ、ストップビット(High)で構成されます。各ビットの長さは1/115,200 s、つまり8.68 µs、100 MHzでは868クロックです。受信器はスタートビットの立ち下がりを待ち、ビット周期の半分だけ待ってビットの中央に入り、線がまだLowであることを確認してから、ビット周期ごとに8回サンプリングします。このビット中央でのサンプリングこそが、両端のわずかなボーレート誤差に対するUARTの耐性を生んでいます。
送信器はその鏡像です。ストップビット、8ビットのデータ、スタートビットを詰めた10ビットのシフトレジスタと、868クロックごとに1ビット進めるカウンタでできています。線はアイドル時にHigh、スタートビットはLowなので、受信器は共通のクロックなしに各フレームの先頭を見つけられます。これが非同期シリアルポートの仕組みのすべてです。
このペアはどれだけのボーレート誤差に耐えられるでしょうか。受信器は最後のデータビットを、スタートエッジから8.5ビット周期後にサンプリングします。両者のクロックが5%ずれていると、そのサンプル点は中央から0.43ビット周期ずれますが、まだビットの中に収まっています。6%では収まりません。実際のUSB-UARTブリッジの精度は1%よりはるかに良く、Basys 3の発振器は0.01%より良いので余裕は大きいのですが、868ではなく870で分周する設計にすると、その余裕の0.2%を無駄に食いつぶします。分周比は必ず実際のクロック周波数から計算してください。
実機で正しく動かすために、3つの工夫をしています。受信ピンはステートマシンに届く前に2段のフリップフロップを通します。ブリッジのクロックはこちらのクロックと無関係で、同期化されていない入力はフリップフロップを準安定状態(メタステーブル)にしかねないからです。受信したバイトは送信器が空くまで保持レジスタに置いておくので、前のバイトを送出中に次のバイトが届いても失われません。そして中央ボタンは同期化してから同期リセットとして使います。これは、リセット設計そのものを扱うレッスン以外ではリセットを同期式にするという当サイトの方針に従ったものです。
4つのLEDは、ハートビート(LD0、約0.75 Hz)、受信アクティビティ(LD1、見えるように40 msに引き伸ばし)、送信器ビジー(LD2)、ボタンで消えるまで保持されるフレーミングエラーフラグ(LD3)を表示します。LD3が点灯したら、ターミナルのボーレートが間違っています。
コード
受信器
// 8N1 UART receiver. Samples each bit in the middle of its period.
module uart_rx #(
parameter integer CLK_HZ = 100_000_000,
parameter integer BAUD = 115_200
) (
input wire clk,
input wire rst,
input wire rx,
output reg [7:0] data,
output reg valid, // one-cycle pulse when data is good
output reg frame_err // one-cycle pulse when the stop bit was low
);
localparam integer CLKS_PER_BIT = CLK_HZ / BAUD; // 868 at 100 MHz
localparam integer HALF_BIT = CLKS_PER_BIT / 2;
localparam [1:0] S_IDLE = 2'd0,
S_START = 2'd1,
S_DATA = 2'd2,
S_STOP = 2'd3;
// Two flip-flops bring the asynchronous pin into the clock domain.
reg rx_meta;
reg rx_sync;
always @(posedge clk) begin
rx_meta <= rx;
rx_sync <= rx_meta;
end
reg [1:0] state;
reg [15:0] tick; // counts clocks within one bit period
reg [2:0] bit_idx;
reg [7:0] shift;
always @(posedge clk) begin
valid <= 1'b0;
frame_err <= 1'b0;
if (rst) begin
state <= S_IDLE;
tick <= 16'd0;
bit_idx <= 3'd0;
shift <= 8'd0;
data <= 8'd0;
end else begin
case (state)
S_IDLE: begin
tick <= 16'd0;
bit_idx <= 3'd0;
if (rx_sync == 1'b0) state <= S_START; // falling edge: start bit
end
S_START: begin
// Wait until the middle of the start bit, confirm it is still low.
if (tick == HALF_BIT[15:0]) begin
tick <= 16'd0;
state <= (rx_sync == 1'b0) ? S_DATA : S_IDLE;
end else begin
tick <= tick + 16'd1;
end
end
S_DATA: begin
if (tick == CLKS_PER_BIT[15:0] - 16'd1) begin
tick <= 16'd0;
shift <= {rx_sync, shift[7:1]}; // LSB arrives first
if (bit_idx == 3'd7) state <= S_STOP;
else bit_idx <= bit_idx + 3'd1;
end else begin
tick <= tick + 16'd1;
end
end
S_STOP: begin
if (tick == CLKS_PER_BIT[15:0] - 16'd1) begin
tick <= 16'd0;
state <= S_IDLE;
if (rx_sync == 1'b1) begin
data <= shift;
valid <= 1'b1;
end else begin
frame_err <= 1'b1;
end
end else begin
tick <= tick + 16'd1;
end
end
default: state <= S_IDLE;
endcase
end
end
endmodule
送信器
// 8N1 UART transmitter. Load a byte with start=1 when busy=0.
module uart_tx #(
parameter integer CLK_HZ = 100_000_000,
parameter integer BAUD = 115_200
) (
input wire clk,
input wire rst,
input wire start,
input wire [7:0] data,
output reg tx,
output wire busy
);
localparam integer CLKS_PER_BIT = CLK_HZ / BAUD;
reg [15:0] tick;
reg [3:0] bit_idx; // 0 = start, 1..8 = data, 9 = stop
reg [9:0] frame; // {stop, data[7:0], start}
reg active;
assign busy = active;
always @(posedge clk) begin
if (rst) begin
tx <= 1'b1;
active <= 1'b0;
tick <= 16'd0;
bit_idx <= 4'd0;
frame <= 10'h3FF;
end else if (!active) begin
tx <= 1'b1;
if (start) begin
frame <= {1'b1, data, 1'b0};
active <= 1'b1;
tick <= 16'd0;
bit_idx <= 4'd0;
end
end else begin
tx <= frame[bit_idx];
if (tick == CLKS_PER_BIT[15:0] - 16'd1) begin
tick <= 16'd0;
if (bit_idx == 4'd9) active <= 1'b0;
else bit_idx <= bit_idx + 4'd1;
end else begin
tick <= tick + 16'd1;
end
end
end
endmodule
大小文字反転付きのトップレベル
ASCIIの大文字と小文字はビット5だけが異なるので、バイトが英字であることを確認した後は、1回のXORで大小文字を反転できます。
// UART echo with a twist: letters come back with their case toggled.
module top #(
parameter integer CLK_HZ = 100_000_000,
parameter integer BAUD = 115_200
) (
input wire clk, // 100 MHz on W5
input wire btn_c, // centre button, synchronous reset
input wire uart_rx_i, // from the USB-UART bridge (B18)
output wire uart_tx_o, // to the USB-UART bridge (A18)
output wire [3:0] led
);
// Two flops bring the push button into the clock domain. It is used as a synchronous reset.
reg [1:0] rst_sync;
always @(posedge clk) rst_sync <= {rst_sync[0], btn_c};
wire rst = rst_sync[1];
wire [7:0] rx_data;
wire rx_valid;
wire rx_err;
wire tx_busy;
uart_rx #(.CLK_HZ(CLK_HZ), .BAUD(BAUD)) u_rx (
.clk(clk), .rst(rst), .rx(uart_rx_i),
.data(rx_data), .valid(rx_valid), .frame_err(rx_err)
);
// Case toggle: ASCII letters differ by bit 5 between upper and lower case.
wire is_upper = (rx_data >= 8'h41) && (rx_data <= 8'h5A);
wire is_lower = (rx_data >= 8'h61) && (rx_data <= 8'h7A);
wire [7:0] echo_data = (is_upper || is_lower) ? (rx_data ^ 8'h20) : rx_data;
// Hold the byte until the transmitter is free.
reg [7:0] pending_data;
reg pending;
reg tx_start;
always @(posedge clk) begin
tx_start <= 1'b0;
if (rst) begin
pending <= 1'b0;
pending_data <= 8'd0;
end else begin
if (rx_valid) begin
pending_data <= echo_data;
pending <= 1'b1;
end
if (pending && !tx_busy && !tx_start) begin
tx_start <= 1'b1;
pending <= 1'b0;
end
end
end
uart_tx #(.CLK_HZ(CLK_HZ), .BAUD(BAUD)) u_tx (
.clk(clk), .rst(rst), .start(tx_start), .data(pending_data),
.tx(uart_tx_o), .busy(tx_busy)
);
// LEDs: heartbeat, receive activity (stretched), transmit busy, framing error (sticky).
reg [26:0] heartbeat;
reg [21:0] rx_stretch;
reg err_sticky;
always @(posedge clk) begin
if (rst) begin
heartbeat <= 27'd0;
rx_stretch <= 22'd0;
err_sticky <= 1'b0;
end else begin
heartbeat <= heartbeat + 27'd1;
if (rx_valid) rx_stretch <= {22{1'b1}};
else if (rx_stretch != 22'd0) rx_stretch <= rx_stretch - 22'd1;
if (rx_err) err_sticky <= 1'b1;
end
end
assign led = {err_sticky, tx_busy, (rx_stretch != 22'd0), heartbeat[26]};
endmodule
テストベンチ
テストベンチは受信線にビットバンギングでフレームを流し込み、同時に送信線を復号します。forkとjoinを使うことで、送信と受信がボード上とまったく同じように重なります。キャリッジリターンと両極端の2つの値を含む9バイトを検査します。
`timescale 1ns / 1ps
// Testbench: sends bytes into the echo design and checks what comes back.
module tb_uart;
localparam integer CLK_HZ = 100_000_000;
localparam integer BAUD = 115_200;
localparam integer BIT_NS = 1_000_000_000 / BAUD; // 8680 ns
reg clk = 1'b0;
reg btn_c = 1'b0;
reg rx_line = 1'b1;
wire tx_line;
wire [3:0] led;
always #5 clk = ~clk; // 100 MHz
top #(.CLK_HZ(CLK_HZ), .BAUD(BAUD)) dut (
.clk(clk), .btn_c(btn_c), .uart_rx_i(rx_line), .uart_tx_o(tx_line), .led(led)
);
// Drive one 8N1 frame onto the receive line.
task send_byte(input [7:0] b);
integer i;
begin
rx_line = 1'b0; // start bit
#BIT_NS;
for (i = 0; i < 8; i = i + 1) begin
rx_line = b[i];
#BIT_NS;
end
rx_line = 1'b1; // stop bit
#BIT_NS;
end
endtask
// Wait for a frame on the transmit line and decode it.
task recv_byte(output [7:0] b);
integer i;
begin
@(negedge tx_line); // start bit edge
#(BIT_NS / 2);
if (tx_line !== 1'b0) $display("WARN: start bit not low");
for (i = 0; i < 8; i = i + 1) begin
#BIT_NS;
b[i] = tx_line;
end
#BIT_NS;
if (tx_line !== 1'b1) $display("WARN: stop bit not high");
end
endtask
reg [7:0] got;
integer errors = 0;
// Expected echo: letters have their case toggled, everything else is unchanged.
function [7:0] expected(input [7:0] c);
begin
if ((c >= "A" && c <= "Z") || (c >= "a" && c <= "z")) expected = c ^ 8'h20;
else expected = c;
end
endfunction
task check(input [7:0] c);
begin
fork
send_byte(c);
recv_byte(got);
join
if (got !== expected(c)) begin
errors = errors + 1;
$display("FAIL: sent 0x%02h, expected 0x%02h, got 0x%02h", c, expected(c), got);
end else begin
$display("ok: sent 0x%02h -> got 0x%02h", c, got);
end
end
endtask
initial begin
$dumpfile("tb_uart.vcd");
$dumpvars(0, tb_uart);
btn_c = 1'b1;
repeat (10) @(posedge clk);
btn_c = 1'b0;
repeat (10) @(posedge clk);
check("F");
check("p");
check("g");
check("A");
check("z");
check("7");
check(8'h0D); // carriage return passes through untouched
check(8'hFF);
check(8'h00);
if (errors == 0) $display("PASS: all bytes echoed correctly");
else $display("FAIL: %0d errors", errors);
$finish;
end
endmodule
制約
## Basys 3 constraints for the UART echo project (all pins LVCMOS33)
## 100 MHz oscillator
set_property -dict { PACKAGE_PIN W5 IOSTANDARD LVCMOS33 } [get_ports clk]
create_clock -add -name sys_clk -period 10.000 -waveform {0 5} [get_ports clk]
## USB-UART bridge. The bridge's TXD drives B18, which is our receive input.
set_property -dict { PACKAGE_PIN B18 IOSTANDARD LVCMOS33 } [get_ports uart_rx_i]
set_property -dict { PACKAGE_PIN A18 IOSTANDARD LVCMOS33 } [get_ports uart_tx_o]
## Centre push button, used as a synchronous reset
set_property -dict { PACKAGE_PIN U18 IOSTANDARD LVCMOS33 } [get_ports btn_c]
## LEDs LD0..LD3
set_property -dict { PACKAGE_PIN U16 IOSTANDARD LVCMOS33 } [get_ports {led[0]}]
set_property -dict { PACKAGE_PIN E19 IOSTANDARD LVCMOS33 } [get_ports {led[1]}]
set_property -dict { PACKAGE_PIN U19 IOSTANDARD LVCMOS33 } [get_ports {led[2]}]
set_property -dict { PACKAGE_PIN V19 IOSTANDARD LVCMOS33 } [get_ports {led[3]}]
## The UART pins and button are asynchronous to the clock, so no timing paths through them.
set_false_path -from [get_ports {uart_rx_i btn_c}]
set_false_path -to [get_ports {uart_tx_o led[*]}]
set_property CFGBVS VCCO [current_design]
set_property CONFIG_VOLTAGE 3.3 [current_design]
ビルドと書き込み
まずシミュレーション
Icarus Verilogをインストール済みなら、4つの.vファイルがあるディレクトリで次を実行します。
iverilog -g2012 -o uart.vvp uart_rx.v uart_tx.v top.v tb_uart.v
vvp uart.vvp
ok:の行が9つとPASS: all bytes echoed correctlyが表示されるはずです。シミュレーション時間にして約1.5 msの実行です。フレームを波形で見たければtb_uart.vcdをGTKWaveで開いてください。
TclでVivadoを使う
次の内容をbuild.tclとしてソースの隣に保存し、ボードを接続した状態でvivado -mode batch -source build.tclを実行します。ビットストリームの生成からデバイスへの書き込みまでを一度に行います。
# Vivado batch build: vivado -mode batch -source build.tcl
set part xc7a35tcpg236-1
create_project -in_memory -part $part
read_verilog {uart_rx.v uart_tx.v top.v}
read_xdc basys3.xdc
synth_design -top top -part $part
opt_design
place_design
route_design
report_timing_summary -file timing.rpt
write_bitstream -force top.bit
# Program the board (needs the Basys 3 connected over USB)
open_hw_manager
connect_hw_server
open_hw_target
set_property PROGRAM.FILE top.bit [current_hw_device]
program_hw_devices [current_hw_device]
close_hw_manager
GUIでVivadoを使う
- Create Projectで、RTL projectを選び、パーツに
xc7a35tcpg236-1を指定します(Digilentのボードファイルをインストール済みなら、BoardsからBasys 3を選んでも構いません)。 - Add Sourcesで3つの設計ファイルを追加し、Copy sources into projectにチェックを入れます。ここではテストベンチを追加しないでください。
- もう一度Add Sourcesを開き、Simulation Sourcesとして
tb_uart.vを追加します。 - Add Constraintsで
basys3.xdcを追加します。 - Vivadoで波形を見たい場合はRun Simulationを実行します。シミュレーション設定で実行時間を2 msにしてください。既定の1 µsでは最初のバイトが届く前に止まってしまいます。TclコンソールにはIcarus Verilogと同じ
ok:とPASSの行が表示されます。 - Generate Bitstreamを実行します。1、2分かかります。タイミングサマリで最悪負スラック(WNS)が正であることを確認してください。100 MHzならこの設計には十分な余裕があります。
- Open Hardware Manager、Open Target、Auto Connectの順に進み、Program Deviceで
top.bitを書き込みます。
これほど小さな設計でも、タイミングレポートは一度見ておく価値があります。実質的なパスは16ビットのtickカウンタとその比較だけで、10 ns周期に対して数nsのスラックが報告されるはずです。入力ポートにinput delay制約がないという警告が出た場合、XDCにある2行のフォールスパス指定が、それらのパスはタイミング対象ではないと宣言しています。
コンフィギュレーションは電源を切ると失われます。恒久的にするには.binファイルを生成し、Hardware ManagerからBasys 3のQSPIフラッシュに書き込みます。手順はRTLからボードへで説明しています。
テスト
シリアルポートを探します。Linuxでは通常/dev/ttyUSB1です(ブリッジには2チャネルあり、UARTは2番目です)。macOSでは/dev/tty.usbserial-XXXXXXXX1を探してください。Windowsではデバイスマネージャーで「USB Serial Port」を確認します。
screen /dev/ttyUSB1 115200
設定は115,200ボー、データ8ビット、パリティなし、ストップビット1、フロー制御なしです。多くのターミナルはローカルエコーをしないので、画面に見えるのはFPGAが返してきたものです。Hello FPGAと打つと画面にはhELLO fpgaが表示されます。数字と記号はそのまま返ってきます。キーを押すたびにLD1が光り、返信の送出中はLD2がちらつきます。中央ボタンを押すとリセットされます。
何も返ってこない場合は、LD0が点滅していること(ビットストリームが読み込まれている)と、LD3が消えていることを確認してください。LD3が点灯していれば、どちらかの側のボーレートが間違っています。文字化けして返ってくる場合も同様です。長いテキストを貼り付けても1バイトも欠けずに動くはずです。115,200ボーでは、次のバイトが届く前に送信器が各バイトを送り終えるからです。
試してみること
- 受信器と送信器の間に16バイトのFIFOを入れ、保持レジスタを取り除く。構造はブロックRAMとFIFOのレッスンで説明しています。
- ボーレートを921,600に変える。1ビット108クロックでも受信器の半ビットサンプリングは機能します。サンプリングがビットから外れるまでに、各側がどれだけのボーレート誤差を許容できるか計算してみてください。
- 反転をもっと賢くする。
#で始まる行をコマンドとして扱う小さなコマンドパーサを作り、たとえば#led 5でLEDを設定できるようにします。 - パリティを追加する。両モジュールを拡張して偶数パリティビットを送信・検査し、パリティエラーをLEDで報告します。
- テストベンチのビットバンギングによる刺激を
uart_txの2つ目のインスタンスに置き換え、設計が自分自身をテストするようにします。
まとめ
- UARTは、2つの小さなステートマシンと、クロックをボーレートで分周するカウンタでできています。
- 各ビットの中央でサンプリングすることで、両端のクロック差に対する耐性が生まれます。
- 非同期入力はすべて、ロジックが見る前に2段のフリップフロップを通します。
- 出力を復号する自己チェック式のテストベンチは、波形を眺めるよりはるかに優れています。
- Basys 3のUSB-UARTブリッジのおかげで、シリアルコンソールに追加のハードウェアは不要です。