プロジェクト

Basys 3でUARTエコー

115200ボーのUART受信器と送信器をVerilogで作り、入力した文字を大文字・小文字を反転して返す回路をシミュレーションしてから、シリアルターミナルで実機テストします。

入門AMD更新 2026年11月13日
ボード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%を無駄に食いつぶします。分周比は必ず実際のクロック周波数から計算してください。

USB-UARTFT2232ブリッジuart_rxB18, 868 clk/bit大小文字反転英字にxor 0x20pending保持レジスタuart_txA18, 10ビットシフトrxdata, validtxが空いたらstarttxLED 4個beat, rx, tx, error
Basys 3上のエコー経路。USB-UARTブリッジのTXDピンがFPGAの受信入力です。

実機で正しく動かすために、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を使う

  1. Create Projectで、RTL projectを選び、パーツにxc7a35tcpg236-1を指定します(Digilentのボードファイルをインストール済みなら、BoardsからBasys 3を選んでも構いません)。
  2. Add Sourcesで3つの設計ファイルを追加し、Copy sources into projectにチェックを入れます。ここではテストベンチを追加しないでください。
  3. もう一度Add Sourcesを開き、Simulation Sourcesとしてtb_uart.vを追加します。
  4. Add Constraintsでbasys3.xdcを追加します。
  5. Vivadoで波形を見たい場合はRun Simulationを実行します。シミュレーション設定で実行時間を2 msにしてください。既定の1 µsでは最初のバイトが届く前に止まってしまいます。TclコンソールにはIcarus Verilogと同じok:とPASSの行が表示されます。
  6. Generate Bitstreamを実行します。1、2分かかります。タイミングサマリで最悪負スラック(WNS)が正であることを確認してください。100 MHzならこの設計には十分な余裕があります。
  7. 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ブリッジのおかげで、シリアルコンソールに追加のハードウェアは不要です。