[test] Add an FPGA test executor, similar to the Verilator executor.

The new test executor, functional_fpga_test.py, is similar to the
existing Verilator executor, but rather than accepting the Verilator
model, a boot loader, and a binary (in vmem form) it accepts a spiflash
executable, a /dev path (the UART port for the FPGA) and the binary.
Much like the Verilator test requires building the Verilator model,
this test expects a ROM-spliced bitfile has already been flashed to the
FPGA.

This test does not provide everything necessary to do a full FPGA test;
it instead serves as a starting point for future test executors and,
more importantly, provides a smoketest for the FPGA flow.
diff --git a/test/systemtest/conftest.py b/test/systemtest/conftest.py
index c81bfb7..1178d85 100644
--- a/test/systemtest/conftest.py
+++ b/test/systemtest/conftest.py
@@ -20,7 +20,8 @@
     parser.addoption("--verilator_model", action="store", default="")
     parser.addoption("--openocd", action="store", default="openocd")
     parser.addoption("--uart_timeout", action="store", default="60")
-
+    parser.addoption("--fpga_uart", action="store", default="")
+    parser.addoption("--spiflash", action="store", default="")
 
 @pytest.hookimpl(tryfirst=True)
 def pytest_exception_interact(node, call, report):
@@ -108,3 +109,17 @@
 def uart_timeout(pytestconfig):
     """Return the timeout in seconds for UART to print PASS."""
     return int(pytestconfig.getoption('uart_timeout'))
+
+@pytest.fixture(scope="session")
+def fpga_uart(pytestconfig):
+    """Return the path to the UART attached to the FPGA."""
+    path = Path(pytestconfig.getoption('fpga_uart')).resolve()
+    assert path.is_file()
+    return path
+
+@pytest.fixture(scope="session")
+def spiflash(pytestconfig):
+    """Return the path to the spiflash executable."""
+    path = Path(pytestconfig.getoption('spiflash')).resolve()
+    assert path.is_file()
+    return path
diff --git a/test/systemtest/functional_fpga_test.py b/test/systemtest/functional_fpga_test.py
new file mode 100644
index 0000000..ea8383c
--- /dev/null
+++ b/test/systemtest/functional_fpga_test.py
@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+r"""Runs a binary on an FPGA,  which is expected to write
+one of "PASS!\r\n" or "FAIL!\r\n" to the UART to determine success or failure.
+Failing to write either will result in a timeout.
+
+This test requires some setup and some configuration. This test expects you
+to have:
+  - A supported FPGA connected to your workstation.
+  - That FPGA must be flashed with a synthesized EarlGrey bitfile, which has
+    been spliced with the OpenTitan bootloader.
+  - A prebuilt spiflash executable.
+
+You must then provide this test with the .bin file for the test, the UART
+device path for the FPGA, and the spiflash executable. For example:
+
+$ cd ${REPO_TOP}
+$ pytest -s -v test/systemtest/functional_fpga_test.py \
+  --test_bin sw/tests/hmac/sw.bin \
+  --fpga_uart /dev/ttyUSB2 \
+  --spiflash sw/host/spiflash/spiflash
+"""
+
+import logging
+from pathlib import Path
+import re
+
+import pytest
+
+import test_utils
+
+logging.basicConfig(level=logging.DEBUG)
+
+
+class TestFunctionalFpga:
+    """
+    Execute a test binary on a locally connected FPGA, using UART
+    output to validate test success or failure.
+    """
+    @pytest.fixture
+    def spiflash_proc(self, tmp_path, spiflash, sw_test_bin):
+        cmd_flash = [str(spiflash), '--input', str(sw_test_bin)]
+        p_flash = test_utils.Process(cmd_flash,
+                                   logdir=str(tmp_path),
+                                   cwd=str(tmp_path),
+                                   startup_done_expect='Running SPI flash update.',
+                                   startup_timeout=10)
+        p_flash.run()
+
+        yield p_flash
+
+        p_flash.terminate()
+
+    def test_execute_binary(self, spiflash_proc, fpga_uart, uart_timeout):
+        """
+        Executes the binary and inspects its UART for "PASS!\r\n" or "FAIL!\r\n".
+        """
+
+        logger = logging.getLogger(__name__)
+
+        # Open the UART device and read line by line until we pass or fail.
+        with fpga_uart.open('rb') as uart_device:
+            uart_fd = uart_device.fileno()
+            pattern = re.compile('.*?(PASS!\r\n|FAIL!\r\n)')
+            match = test_utils.stream_fd_to_log(uart_fd, logger, pattern,
+                                                uart_timeout)
+
+            if match == None:
+                pytest.fail('Deadline exceeded: did not see PASS! or FAIL! within %ds.', uart_timeout)
+
+            if match.group(1) == 'PASS!\r\n':
+                logger.debug('Got PASS! from binary.')
+            else:
+                pytest.fail('Got FAIL! from binary.')