[util] Add script and first template to autogen (some) testutils.

This partially addresses #9038 by auto-generating a higher-level
testutils library to service ISRs. Specifically this commit:

1. adds a Python script, `util/autogen_testutils.py`, that renders any
   testutils templates that live in util/autogen_testutils/, and
2. adds a `isr_testutils.h.tpl` header template.

Signed-off-by: Timothy Trippel <ttrippel@google.com>
diff --git a/util/autogen_testutils.py b/util/autogen_testutils.py
new file mode 100755
index 0000000..5284a9d
--- /dev/null
+++ b/util/autogen_testutils.py
@@ -0,0 +1,89 @@
+#!/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
+"""autogen_testutils.py is a script for auto-generating a portion of the
+`testutils` libraries from Mako templates.
+
+`testutils` libraries are testing libraries that sit a layer above the DIFs
+that aid in writing chip-level tests by enabling test developers to re-use
+code that calls a specific collection of DIFs.
+
+To render all testutil templates, run the script with:
+$ util/autogen_testutils.py
+"""
+
+import glob
+import logging
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+from mako.template import Template
+
+from autogen_banner import get_autogen_banner
+from make_new_dif.ip import Ip
+
+# This file is $REPO_TOP/util/autogen_testutils.py, so it takes two parent()
+# calls to get back to the top.
+REPO_TOP = Path(__file__).resolve().parent.parent
+
+
+def main():
+    # Check clang-format is installed.
+    assert (shutil.which("clang-format") and
+            "ERROR: clang-format must be installed to format "
+            " autogenerated code to pass OpenTitan CI checks.")
+
+    # Define input/output directories.
+    autogen_dif_directory = REPO_TOP / "sw/device/lib/dif/autogen"
+    testutils_templates_dir = REPO_TOP / "util/autogen_testutils"
+    autogen_testutils_dir = REPO_TOP / "sw/device/lib/testing/autogen"
+
+    # Create list of IPs to generate shared testutils code for. This is all IPs
+    # that have a DIF library, that the testutils functions can use.
+    ips_with_difs = []
+    for autogen_dif_filename in glob.iglob(str(autogen_dif_directory / "*.h")):
+        # NOTE: the line below takes as input a file path
+        # (/path/to/dif_uart_autogen.c) and returns the IP name in lower
+        # case snake mode (i.e., uart).
+        ip_name_snake = Path(autogen_dif_filename).stem[4:-8]
+        # NOTE: ip.name_long_* not needed for auto-generated files which
+        # are the only files (re-)generated in batch mode.
+        ips_with_difs.append(Ip(ip_name_snake, "AUTOGEN"))
+    ips_with_difs.sort(key=lambda ip: ip.name_snake)
+
+    # Create output directories if needed.
+    autogen_testutils_dir.mkdir(exist_ok=True)
+
+    # Auto-generate testutils files.
+    for suffix in [".h", ".c"]:
+        for testutils_template_path_str in glob.iglob(
+                str(testutils_templates_dir / f"*{suffix}.tpl")):
+            testutils_template_path = Path(testutils_template_path_str)
+
+            # Read in template, render it, and write it to the output file.
+            testutils_template = Template(testutils_template_path.read_text())
+            testutils = Path(autogen_testutils_dir /
+                             testutils_template_path.stem)
+            testutils.write_text(
+                testutils_template.render(ips_with_difs=ips_with_difs,
+                                          autogen_banner=get_autogen_banner(
+                                              "util/autogen_testutils.py",
+                                              comment="//")))
+
+            # Format autogenerated file with clang-format.
+            try:
+                subprocess.check_call(["clang-format", "-i", testutils])
+            except subprocess.CalledProcessError:
+                logging.error(
+                    f"failed to format {testutils} with clang-format.")
+                sys.exit(1)
+
+            print("testutils \"{}\" successfully written to {}.".format(
+                suffix, str(testutils)))
+
+
+if __name__ == "__main__":
+    main()
diff --git a/util/autogen_testutils/isr_testutils.h.tpl b/util/autogen_testutils/isr_testutils.h.tpl
new file mode 100644
index 0000000..9e54895
--- /dev/null
+++ b/util/autogen_testutils/isr_testutils.h.tpl
@@ -0,0 +1,31 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+
+${autogen_banner}
+
+#ifndef OPENTITAN_SW_DEVICE_LIB_TESTING_AUTOGEN_ISR_TESTUTILS_H_
+#define OPENTITAN_SW_DEVICE_LIB_TESTING_AUTOGEN_ISR_TESTUTILS_H_
+
+% for ip in ips_with_difs:
+  % if ip.irqs:
+  /* 
+   * Services an ${ip.name_snake} IRQ at the IP that raised it, and verifies this
+   * matches the IRQ that was raised at the PLIC.
+   *  
+   * @param ${ip.name_snake} A(n) ${ip.name_snake} DIF handle. 
+   * @param plic_irq_id The triggered PLIC IRQ ID. 
+   * @param plic_${ip.name_snake}_start_irq_id The PLIC IRQ ID where ${ip.name_snake} starts. 
+   * @param expected_${ip.name_snake}_irq The expected ${ip.name_snake} IRQ. 
+   * @param is_only_irq This is the only IRQ expected to be raised.
+   */
+  void isr_testutils_${ip.name_snake}(dif_${ip.name_snake}_t *${ip.name_snake}, 
+                                      dif_rv_plic_irq_id_t plic_irq_id, 
+                                      dif_rv_plic_irq_id_t plic_${ip.name_snake}_start_irq_id, 
+                                      dif_${ip.name_snake}_irq_id_t expected_${ip.name_snake}_irq,
+                                      bool is_only_irq);
+
+  % endif
+% endfor
+
+#endif  // OPENTITAN_SW_DEVICE_LIB_TESTING_AUTOGEN_ISR_TESTUTILS_H_