[test] Add OpenOCD compliance test

Based on @imphil's CI system tests infrastructure. This commit adds a
test that runs the OpenOCD compliance test against a Verilator target.
The test takes in external parameters to make it easy to integrate with
CI.

Sample run (takes almost 2m to complete):

```console
$ pytest -s -v test/systemtest/openocd_verilator_test.py \
  --test_bin=sw/build/sw.vmem \
  --rom_bin=sw/build/rom.vmem \
  --verilator_model=build/lowrisc_systems_top_earlgrey_verilator_0.1/sim-verilator/Vtop_earlgrey_verilator
```

Integration with CI is blocked until issue #413 is resolved.
diff --git a/test/systemtest/.gitignore b/test/systemtest/.gitignore
new file mode 100644
index 0000000..16d3c4d
--- /dev/null
+++ b/test/systemtest/.gitignore
@@ -0,0 +1 @@
+.cache
diff --git a/test/systemtest/conftest.py b/test/systemtest/conftest.py
new file mode 100644
index 0000000..b2504ba
--- /dev/null
+++ b/test/systemtest/conftest.py
@@ -0,0 +1,95 @@
+#!/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
+
+import logging
+import os
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+import yaml
+
+
+def pytest_addoption(parser):
+    """Test harness configuration options."""
+    parser.addoption("--test_bin", action="store", default="")
+    parser.addoption("--rom_bin", action="store", default="")
+    parser.addoption("--verilator_model", action="store", default="")
+    parser.addoption("--openocd", action="store", default="openocd")
+
+
+@pytest.hookimpl(tryfirst=True)
+def pytest_exception_interact(node, call, report):
+    """Dump all log files in case of a test failure."""
+    try:
+        if not report.failed:
+            return
+        if not 'tmpdir' in node.funcargs:
+            return
+    except:
+        return
+
+    tmpdir = str(node.funcargs['tmpdir'])
+    print("\n\n")
+    print("================= DUMP OF ALL TEMPORARY FILES =================")
+
+    for f in os.listdir(tmpdir):
+        f_abs = os.path.join(tmpdir, f)
+        if not os.path.isfile(f_abs):
+            continue
+        print("vvvvvvvvvvvvvvvvvvvv {} vvvvvvvvvvvvvvvvvvvv".format(f))
+        with open(f_abs, 'r') as fp:
+            print(fp.read())
+        print("^^^^^^^^^^^^^^^^^^^^ {} ^^^^^^^^^^^^^^^^^^^^\n\n".format(f))
+
+
+@pytest.fixture(scope="session")
+def localconf(request):
+    """Host-local configuration."""
+    if os.getenv('OPENTITAN_TEST_LOCALCONF') and os.path.isfile(
+            os.environ['OPENTITAN_TEST_LOCALCONF']):
+        localconf_yaml_file = os.environ['OPENTITAN_TEST_LOCALCONF']
+    else:
+        XDG_CONFIG_HOME = os.getenv(
+            'XDG_CONFIG_HOME', os.path.join(os.environ['HOME'], '.config'))
+        localconf_yaml_file = os.path.join(XDG_CONFIG_HOME, 'opentitan',
+                                           'test-localconf.yaml')
+    logging.getLogger(__name__).info('Reading configuration from ' +
+                                     localconf_yaml_file)
+
+    with open(str(localconf_yaml_file), 'r') as fp:
+        return yaml.load(fp)
+
+
+@pytest.fixture(scope="session")
+def topsrcdir(request):
+    """Return the top-level source directory as Path object."""
+    # TODO: Consider making this configurable using a pytest arg.
+    return str(Path(os.path.dirname(__file__)) / '..' / '..')
+
+
+@pytest.fixture(scope="session")
+def sw_test_bin(pytestconfig):
+    """Return path to software test binary."""
+    return pytestconfig.getoption('test_bin')
+
+
+@pytest.fixture(scope="session")
+def rom_bin(pytestconfig):
+    """Return path to boot_rom binary."""
+    return pytestconfig.getoption('rom_bin')
+
+
+@pytest.fixture(scope="session")
+def sim_top_build(pytestconfig):
+    """Return path to Verilator sim model."""
+    return pytestconfig.getoption('verilator_model')
+
+
+@pytest.fixture(scope="session")
+def openocd(pytestconfig):
+    """Return path to OpenOCD executable."""
+    return pytestconfig.getoption('openocd')
diff --git a/test/systemtest/openocd_verilator_test.py b/test/systemtest/openocd_verilator_test.py
new file mode 100644
index 0000000..e912484
--- /dev/null
+++ b/test/systemtest/openocd_verilator_test.py
@@ -0,0 +1,90 @@
+#!/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 OpenOCD compliance test against Verilator target.
+
+This test requires some configuration options. Use the following steps to
+run the test manually after building Verilator and the sw/boot_rom and
+sw/examples/hello_world targets.
+
+$ cd ${REPO_TOP}
+$ pytest -s -v test/systemtest/openocd_verilator_test.py \
+  --test_bin sw/examples/hello_world/sw.vmem \
+  --rom_bin sw/boot_rom/rom.vmem \
+  --verilator_model build/lowrisc_systems_top_earlgrey_verilator_0.1/sim-verilator/Vtop_earlgrey_verilator
+
+In some cases the pytest environment may not be able to find the openocd binary.
+To work around this issue, run the test with an additional configuration option:
+
+  --openocd /tools/openocd/bin/openocd
+"""
+
+import logging
+import os
+from pathlib import Path
+import re
+import subprocess
+import time
+
+import pytest
+
+import test_utils
+
+logging.basicConfig(level=logging.DEBUG)
+
+
+class TestCoreVerilator:
+    """Test core functionality in a Verilator-simulated hardware build."""
+
+    @pytest.fixture
+    def sim_top_earlgrey(self, tmp_path, sim_top_build, sw_test_bin, rom_bin):
+        assert Path(sw_test_bin).is_file()
+        assert Path(rom_bin).is_file()
+        assert Path(sim_top_build).is_file()
+
+        cmd_sim = [
+            str(Path(sim_top_build).resolve()),
+            '--meminit', str(Path(sw_test_bin).resolve()),
+            '--rominit', str(Path(rom_bin).resolve())
+        ]
+        p_sim = test_utils.Process(
+            cmd_sim,
+            logdir=str(tmp_path),
+            cwd=str(tmp_path),
+            startup_done_expect='Simulation running',
+            startup_timeout=10)
+        p_sim.run()
+
+        yield p_sim
+
+        p_sim.terminate()
+
+    def test_openocd_riscv_compliancetest(self, tmp_path, sim_top_earlgrey,
+                                          topsrcdir, openocd):
+        """Run RISC-V Debug compliance test built into OpenOCD."""
+        assert subprocess.call([openocd, '--version']) == 0
+        cmd_openocd = [
+            openocd,
+            '-s', str(Path(topsrcdir) / 'util' / 'openocd'),
+            '-f', 'board/lowrisc-earlgrey-verilator.cfg',
+            '-c', 'init; riscv test_compliance; shutdown'
+        ]
+        p_openocd = test_utils.Process(
+            cmd_openocd, logdir=str(tmp_path), cwd=str(tmp_path))
+        p_openocd.run()
+
+        logging.getLogger(__name__).info(
+            "OpenOCD should terminate itself; give it up to 5 minutes")
+        p_openocd.proc.wait(timeout=300)
+        assert p_openocd.proc.returncode == 0
+
+        logging.getLogger(__name__).info(
+            "Now wait 60 seconds until the program has finished execution")
+        time.sleep(60)
+
+        try:
+            p_openocd.terminate()
+        except ProcessLookupError:
+            # process is already dead
+            pass
diff --git a/test/systemtest/test_utils.py b/test/systemtest/test_utils.py
new file mode 100644
index 0000000..87f0565
--- /dev/null
+++ b/test/systemtest/test_utils.py
@@ -0,0 +1,219 @@
+#!/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
+
+import difflib
+import filecmp
+import inspect
+import logging
+import os
+import re
+import shlex
+import signal
+import subprocess
+import time
+
+
+class Process:
+    """Utility class used to spawn an interact with processs.s"""
+
+    def __init__(self,
+                 cmd,
+                 logdir,
+                 cwd=None,
+                 startup_done_expect=None,
+                 startup_timeout=None):
+        """Creates Process instance.
+
+        Args:
+            cmd: Command line argument used to spawn process.
+            logdir: Directory used to store STDOUT and STDERR output.
+            cwd: Working directory used to spawn process.
+            startup_done_expect: Pattern used to check at process startup time.
+            startup_timeout: Timeout in seconds for |startup_done_expect| check.
+        """
+        if isinstance(cmd, str):
+            self.cmd = shlex.split(cmd)
+        else:
+            self.cmd = cmd
+        self.logdir = logdir
+        self.cwd = cwd
+        self.startup_done_expect = startup_done_expect
+        self.startup_timeout = startup_timeout
+        self.proc = None
+        self.logger = logging.getLogger(__name__)
+
+        self._f_stdout = None
+        self._f_stderr = None
+        self._f_stdout_r = None
+        self._f_stderr_r = None
+
+    def __del__(self):
+        try:
+            self.proc.kill()
+            self._f_stdout.close()
+            self._f_stderr.close()
+            self._f_stdout_r.close()
+            self._f_stderr_r.close()
+        except:
+            pass
+
+    def run(self):
+        """Start process with command line configured in constructor."""
+        cmd_name = os.path.basename(self.cmd[0])
+
+        # Enforce line-buffered STDOUT even when sending STDOUT/STDERR to file.
+        # If applications don't fflush() STDOUT manually, STDOUT goes through
+        # a 4kB buffer before we see any output, which prevents searching for
+        # the string indicating a successful startup.
+        # see discussion at http://www.pixelbeat.org/programming/stdio_buffering/
+        cmd = ['stdbuf', '-oL'] + self.cmd
+        self.logger.info("Running command " + ' '.join(cmd))
+
+        logfile_stdout = os.path.join(self.logdir,
+                                      "{}.stdout".format(cmd_name))
+        logfile_stderr = os.path.join(self.logdir,
+                                      "{}.stderr".format(cmd_name))
+        self.logger.debug("Capturing STDOUT at " + logfile_stdout)
+        self.logger.debug("Capturing STDERR at " + logfile_stderr)
+
+        self._f_stdout = open(logfile_stdout, 'w')
+        self._f_stderr = open(logfile_stderr, 'w')
+        self.proc = subprocess.Popen(
+            cmd,
+            cwd=self.cwd,
+            universal_newlines=True,
+            bufsize=1,
+            stdin=subprocess.PIPE,
+            stdout=self._f_stdout,
+            stderr=self._f_stderr)
+
+        self._f_stdout_r = open(logfile_stdout, 'r')
+        self._f_stderr_r = open(logfile_stderr, 'r')
+
+        # no startup match pattern given => startup done!
+        if self.startup_done_expect == None:
+            return True
+
+        # check if the string indicating a successful startup appears in the
+        # the program output (STDOUT or STDERR)
+        init_done = self._find_in_output(
+            pattern=self.startup_done_expect, timeout=self.startup_timeout)
+
+        if init_done == None:
+            raise subprocess.TimeoutExpired
+
+        self.logger.info("Startup sequence matched, startup done.")
+
+        return True
+
+    def terminate(self):
+        """Terminates process started by run call."""
+        if not self.proc:
+            return
+        self.proc.terminate()
+
+    def send_ctrl_c(self):
+        """Sends SIGINT to process started by run call."""
+        if not self.proc:
+            return
+        self.proc.send_signal(signal.SIGINT)
+
+    def expect(self, stdin_data=None, pattern=None, timeout=None):
+        """Write send to STDIN and check if the output is as expected.
+
+        Args:
+            stdin_data: Data to send to STDIN.
+            pattern: Pattern to search for after sending |stdin_data|.
+            timeout: Timeout in seconds for |pattern| check.
+        Returns:
+            True if |pattern| found, False otherwise.
+        """
+        # We don't get STDOUT/STDERR from subprocess.communicate() as it's
+        # redirected to file. We need to read the files instead.
+
+        # XXX: races (false positives) can happen here if output is generated
+        # before the input is sent to the process.
+        if pattern == None:
+            self._f_stdout_r.seek(0, 2)
+
+        self.proc.stdin.write(stdin_data)
+        self.proc.stdin.flush()
+
+        if pattern == None:
+            return True
+
+        return self._find_in_output(pattern, timeout) != None
+
+    def _find_in_output(self, pattern, timeout):
+        """Read STDOUT and STDERR to find an expected pattern.
+
+        Both streams are reset to the start of the stream before searching.
+        pattern can be of two types:
+        1. A regular expression (a re object). In this case, the pattern is
+           matched against all lines and the result of re.match(pattern) is
+           returned. Multi-line matches are not supported.
+        2. A string. In this case pattern is compared to each line with
+           startswith(), and the full matching line is returned on a match.
+        If no match is found None is returned.
+        timeout is given in seconds.
+
+        Args:
+            pattern: Pattern to search for in STDOUT or STDERR.
+            timeout: Timeout in seconds for |pattern| check.
+        Returns:
+            String containing |pattern|, None otherwise.
+        """
+
+        if timeout != None:
+            t_end = time.time() + timeout
+
+
+        # reset streams
+        self._f_stdout_r.seek(0)
+        self._f_stderr_r.seek(0)
+
+        while True:
+            # check STDOUT as long as there is one
+            i = 0
+            for line in self._f_stdout_r:
+                i += 1
+                if hasattr(pattern, "match"):
+                    m = pattern.match(line)
+                    if m:
+                        return m
+                else:
+                    if line.startswith(pattern):
+                        return line
+
+                # Check if we exceed the timeout while reading from STDOUT
+                # do so only every 100 lines to reduce the performance impact.
+                if timeout != None and i % 100 == 99 and time.time() >= t_end:
+                    break
+
+            # check STDERR as long as there is one
+            i = 0
+            for line in self._f_stderr_r:
+                i += 1
+                if hasattr(pattern, "match"):
+                    m = pattern.match(line)
+                    if m:
+                        return m
+                else:
+                    if line.startswith(pattern):
+                        return line
+
+                # Check if we exceed the timeout while reading from STDERR
+                # do so only every 100 lines to reduce the performance impact.
+                if timeout != None and i % 100 == 99 and time.time() >= t_end:
+                    break
+
+            # wait for 200ms for new output
+            if timeout != None:
+                try:
+                    self.proc.wait(timeout=0.2)
+                except subprocess.TimeoutExpired:
+                    pass
+
+        return None