[sw/ottf] Initial prototype of OpenTitan Test Framework

This prototype of the OpenTitan Test Framework (OTTF) contains:

-Code to port FreeRTOS to run on OpenTitan hardware.
-Test setup and teardown code (similar to the prior test_main.c).
-An example Earlgrey chip-level test.

Signed-off-by: Timothy Trippel <ttrippel@google.com>
diff --git a/sw/device/lib/meson.build b/sw/device/lib/meson.build
index f223c4e..ec154b1 100644
--- a/sw/device/lib/meson.build
+++ b/sw/device/lib/meson.build
@@ -7,7 +7,6 @@
 subdir('crt')
 subdir('dif')
 subdir('runtime')
-subdir('testing')
 
 # Flash controller library (sw_lib_flash_ctrl)
 sw_lib_flash_ctrl = declare_dependency(
@@ -98,3 +97,5 @@
     ]
   )
 )
+
+subdir('testing')
diff --git a/sw/device/lib/testing/meson.build b/sw/device/lib/testing/meson.build
index 8472180..364e067 100644
--- a/sw/device/lib/testing/meson.build
+++ b/sw/device/lib/testing/meson.build
@@ -2,7 +2,6 @@
 # Licensed under the Apache License, Version 2.0, see LICENSE for details.
 # SPDX-License-Identifier: Apache-2.0
 
-
 # hardware entropy complex (entropy_src, csrng, edn) test utilities.
 sw_lib_testing_entropy_testutils = declare_dependency(
   link_with: static_library(
diff --git a/sw/device/lib/testing/test_framework/FreeRTOSConfig.h b/sw/device/lib/testing/test_framework/FreeRTOSConfig.h
new file mode 100644
index 0000000..d8db2ba
--- /dev/null
+++ b/sw/device/lib/testing/test_framework/FreeRTOSConfig.h
@@ -0,0 +1,68 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+//
+#ifndef OPENTITAN_SW_DEVICE_LIB_TESTING_TEST_FRAMEWORK_FREERTOSCONFIG_H_
+#define OPENTITAN_SW_DEVICE_LIB_TESTING_TEST_FRAMEWORK_FREERTOSCONFIG_H_
+
+#include "sw/device/lib/arch/device.h"
+
+// These macros configure FreeRTOS. A description of each macro can be found
+// here: https://www.freertos.org/a00110.html
+
+// NOTE: the macro names below do NOT, and cannot, conform to the style
+// guide, since they are specific to FreeRTOS.
+
+// Main FreeRTOS configs.
+#define configUSE_PREEMPTION 0
+#define configUSE_IDLE_HOOK 0
+#define configUSE_TICK_HOOK 0
+#define configTICK_RATE_HZ ((TickType_t)5)
+#define configMAX_PRIORITIES 5
+#define configMINIMAL_STACK_SIZE ((unsigned short)512)
+// TODO: would be better if this was computed based on macros in the
+// autogenerated toplevel header and/or the values defined the linker script.
+// Setting this to 0x15000u for now. need a way to import this from
+#define configTOTAL_HEAP_SIZE ((size_t)0x15000u)
+#define configMAX_TASK_NAME_LEN 16
+#define configUSE_TRACE_FACILITY 0
+#define configUSE_16_BIT_TICKS 0
+#define configIDLE_SHOULD_YIELD 0
+#define configUSE_MUTEXES 0
+#define configQUEUE_REGISTRY_SIZE 0
+#define configCHECK_FOR_STACK_OVERFLOW 0
+#define configUSE_RECURSIVE_MUTEXES 0
+#define configUSE_MALLOC_FAILED_HOOK 1
+#define configUSE_APPLICATION_TASK_TAG 0
+#define configUSE_COUNTING_SEMAPHORES 0
+#define configGENERATE_RUN_TIME_STATS 0
+#define configUSE_PORT_OPTIMISED_TASK_SELECTION 1
+
+// Co-routines.
+#define configUSE_CO_ROUTINES 0
+#define configMAX_CO_ROUTINE_PRIORITIES 2
+
+// Software timers.
+#define configUSE_TIMERS 0
+
+// Task priorities. Allow these to be overridden.
+#ifndef uartPRIMARY_PRIORITY
+#define uartPRIMARY_PRIORITY (configMAX_PRIORITIES - 3)
+#endif
+
+// Set the following definitions to 1 to include the API function, or zero to
+// exclude the API function.
+#define INCLUDE_vTaskPrioritySet 1
+#define INCLUDE_uxTaskPriorityGet 1
+#define INCLUDE_vTaskDelete 1
+#define INCLUDE_vTaskCleanUpResources 1
+#define INCLUDE_vTaskSuspend 1
+#define INCLUDE_vTaskDelayUntil 1
+#define INCLUDE_vTaskDelay 1
+#define INCLUDE_eTaskGetState 1
+#define INCLUDE_xTimerPendFunctionCall 1
+#define INCLUDE_xTaskAbortDelay 1
+#define INCLUDE_xTaskGetHandle 1
+#define INCLUDE_xSemaphoreGetMutexHolder 0
+
+#endif  // OPENTITAN_SW_DEVICE_LIB_TESTING_TEST_FRAMEWORK_FREERTOSCONFIG_H_
diff --git a/sw/device/lib/testing/test_framework/example_earlgrey_test.c b/sw/device/lib/testing/test_framework/example_earlgrey_test.c
new file mode 100644
index 0000000..ff9d978
--- /dev/null
+++ b/sw/device/lib/testing/test_framework/example_earlgrey_test.c
@@ -0,0 +1,43 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+
+#include <sw/device/lib/testing/test_framework/ottf.h>
+
+#include "sw/device/lib/dif/dif_rv_timer.h"
+#include "sw/device/lib/runtime/log.h"
+#include "sw/device/lib/testing/check.h"
+#include "sw/vendor/freertos_freertos_kernel/include/FreeRTOS.h"
+#include "sw/vendor/freertos_freertos_kernel/include/task.h"
+
+#include "hw/top_earlgrey/sw/autogen/top_earlgrey.h"
+
+const test_config_t kTestConfig = {
+    .can_clobber_uart = false,
+    .test_name = "ExampleTest",
+};
+
+// This example test just queries the RV Timer count and logs it over UART.
+// Currently, this test runs forever, but once test teardown logic has been
+// implemented this example will be updated.
+void test_main(void *result) {
+  mmio_region_t timer_reg =
+      mmio_region_from_addr(TOP_EARLGREY_RV_TIMER_BASE_ADDR);
+  dif_rv_timer_t timer = {
+      .base_addr = timer_reg,
+      {.hart_count = 1, .comparator_count = 1},
+  };
+  uint64_t current_time;
+  const uint32_t kHart = (uint32_t)kTopEarlgreyPlicTargetIbex0;
+
+  while (true) {
+    CHECK(dif_rv_timer_counter_read(&timer, kHart, &current_time) ==
+          kDifRvTimerOk);
+    LOG_INFO("(FreeRTOS Task) Current Time: %u", (uint32_t)current_time);
+  }
+
+  *(bool *)result = true;
+
+  // Calling vTaskDelete() with NULL triggers a task to delete itself.
+  vTaskDelete(NULL);
+}
diff --git a/sw/device/lib/testing/test_framework/freertos_hooks.c b/sw/device/lib/testing/test_framework/freertos_hooks.c
new file mode 100644
index 0000000..d621ac6
--- /dev/null
+++ b/sw/device/lib/testing/test_framework/freertos_hooks.c
@@ -0,0 +1,28 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+
+#include "sw/device/lib/testing/test_framework/freertos_hooks.h"
+
+#include "sw/device/lib/runtime/hart.h"
+#include "sw/device/lib/runtime/log.h"
+#include "sw/vendor/freertos_freertos_kernel/include/FreeRTOS.h"
+
+// NOTE: the function names below do NOT, and cannot, conform to the style
+// guide, since they are specific implementations of FreeRTOS defined functions.
+
+void vApplicationMallocFailedHook(void) {
+  // TODO: communicate this event back to the host.
+  LOG_INFO("Malloc Failed.");
+  taskDISABLE_INTERRUPTS();
+  abort();
+}
+
+void vApplicationStackOverflowHook(TaskHandle_t pxTask, char *pcTaskName) {
+  // TODO: communicate this event back to the host.
+  LOG_INFO("Stack Overflow.");
+  (void)pcTaskName;
+  (void)pxTask;
+  taskDISABLE_INTERRUPTS();
+  abort();
+}
diff --git a/sw/device/lib/testing/test_framework/freertos_hooks.h b/sw/device/lib/testing/test_framework/freertos_hooks.h
new file mode 100644
index 0000000..4123980
--- /dev/null
+++ b/sw/device/lib/testing/test_framework/freertos_hooks.h
@@ -0,0 +1,29 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+
+#ifndef OPENTITAN_SW_DEVICE_LIB_TESTING_TEST_FRAMEWORK_FREERTOS_HOOKS_H_
+#define OPENTITAN_SW_DEVICE_LIB_TESTING_TEST_FRAMEWORK_FREERTOS_HOOKS_H_
+
+#include "sw/vendor/freertos_freertos_kernel/include/FreeRTOS.h"
+#include "sw/vendor/freertos_freertos_kernel/include/task.h"
+
+// NOTE: the function names below do NOT, and cannot, conform to the style
+// guide, since they are specific implementations of FreeRTOS defined functions.
+
+/**
+ * This is called if configUSE_MALLOC_FAILED_HOOK is set to 1 in
+ * FreeRTOSConfig.h, and a call to pvPortMalloc() fails.
+ */
+void vApplicationMallocFailedHook(void);
+
+/**
+ * This is called if a stack overflow is detected, and
+ * configCHECK_FOR_STACK_OVERFLOW is set to 1 or 2 in FreeRTOSConfig.h.
+ *
+ * @param pxTask FreeRTOS task handle.
+ * @param pcTaskName
+ */
+void vApplicationStackOverflowHook(TaskHandle_t pxTask, char *pcTaskName);
+
+#endif  // OPENTITAN_SW_DEVICE_LIB_TESTING_TEST_FRAMEWORK_FREERTOS_HOOKS_H_
diff --git a/sw/device/lib/testing/test_framework/freertos_port.S b/sw/device/lib/testing/test_framework/freertos_port.S
new file mode 100644
index 0000000..a11b1f6
--- /dev/null
+++ b/sw/device/lib/testing/test_framework/freertos_port.S
@@ -0,0 +1,168 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+
+// Ibex does not implement additional registers beyond the RV32I spec.
+#define PORT_WORD_SIZE 4
+#define PORT_CONTEXT_SIZE (30 * PORT_WORD_SIZE)
+
+.extern pxCurrentTCB
+.extern xISRStackTop
+
+// -----------------------------------------------------------------------------
+
+/**
+ * FreeRTOS, expects this function to exist and uses it to start the first task. 
+ */
+.balign 8
+.global xPortStartFirstTask
+.type xPortStartFirstTask, @function
+xPortStartFirstTask:
+
+  // Load the stack pointer for the current TCB (just going to clobber sp here
+  // since we are setting it here anyway).
+  lw  sp, pxCurrentTCB
+  lw  sp, 0(sp)
+
+  // NOTE: for starting the FreeRTOS scheduler, the exception return address is
+  // used as the function return address. See pxPortInitialiseStack below.
+  lw  ra, 0(sp)
+
+  // Restore registers initialized on task start.
+  lw   t1,  3 * PORT_WORD_SIZE(sp)
+  lw   t2,  4 * PORT_WORD_SIZE(sp)
+  lw   s0,  5 * PORT_WORD_SIZE(sp)
+  lw   s1,  6 * PORT_WORD_SIZE(sp)
+  lw   a0,  7 * PORT_WORD_SIZE(sp) // task parameters (pvParameters)
+  lw   a1,  8 * PORT_WORD_SIZE(sp)
+  lw   a2,  9 * PORT_WORD_SIZE(sp)
+  lw   a3, 10 * PORT_WORD_SIZE(sp)
+  lw   a4, 11 * PORT_WORD_SIZE(sp)
+  lw   a5, 12 * PORT_WORD_SIZE(sp)
+  lw   a6, 13 * PORT_WORD_SIZE(sp)
+  lw   a7, 14 * PORT_WORD_SIZE(sp)
+  lw   s2, 15 * PORT_WORD_SIZE(sp)
+  lw   s3, 16 * PORT_WORD_SIZE(sp)
+  lw   s4, 17 * PORT_WORD_SIZE(sp)
+  lw   s5, 18 * PORT_WORD_SIZE(sp)
+  lw   s6, 19 * PORT_WORD_SIZE(sp)
+  lw   s7, 20 * PORT_WORD_SIZE(sp)
+  lw   s8, 21 * PORT_WORD_SIZE(sp)
+  lw   s9, 22 * PORT_WORD_SIZE(sp)
+  lw  s10, 23 * PORT_WORD_SIZE(sp)
+  lw  s11, 24 * PORT_WORD_SIZE(sp)
+  lw   t3, 25 * PORT_WORD_SIZE(sp)
+  lw   t4, 26 * PORT_WORD_SIZE(sp)
+  lw   t5, 27 * PORT_WORD_SIZE(sp)
+  lw   t6, 28 * PORT_WORD_SIZE(sp)
+
+  // Initialize t0 to the value of MSTATUS with global interrupts enabled, which
+  // is required because this returns with ret, not eret.
+  lw t0, 29 * PORT_WORD_SIZE(sp) // Load the MSTATUS state from the stack.
+  ori t0, t0, 1<<3               // Set MIE field.
+  csrw mstatus, t0               // Ibex interrupts enabled from here!
+
+  // Restore t0 register from the stack (after using it to manipulate MSTATUS).
+  lw t0, 2 * PORT_WORD_SIZE(sp)
+
+  // Update the stack pointer (shrinking the stack).
+  addi sp, sp, PORT_CONTEXT_SIZE
+
+  ret
+
+  // Set size so this function can be disassembled.
+  .size xPortStartFirstTask, .-xPortStartFirstTask
+
+// -----------------------------------------------------------------------------
+
+/** 
+ * The prototype for this function depends on configurations defined in
+ * FreeRTOSConfig.h, and is defined in:
+ * sw/vendor/freertos_freertos_kernel/include/portable.h
+ * 
+ * The implementation of this assembly function assumes the prototype for this
+ * function looks like:
+ * 
+ * StackType_t *pxPortInitialiseStack(StackType_t *pxTopOfStack,
+ *                                    TaskFunction_t pxCode,
+ *                                    void *pvParameters);
+ * 
+ * TODO: add some checks to verify this is the configured prototype.
+ * TODO: configure to allow use of checking for stack overflows.
+ * TODO: configure return address for first (main) task.
+ * 
+ * As per the standard RISC-V ABI pxTopcOfStack is passed in in a0, pxCode in
+ * a1, and pvParameters in a2. The new top of stack is passed out in a0.
+ * 
+ * The RISC-V context is saved to FreeRTOS tasks in the following stack frame,
+ * where the global and thread pointers are currently assumed to be constant,
+ * and therefore are not saved:
+ * 
+ * ---Stack Bottom---
+ * ---............---
+ * Offset - Reg/Value
+ *     29 - mstatus
+ *     28 - t6 (x31)
+ *     27 - t5 (x30)
+ *     26 - t4 (x29)
+ *     25 - t3 (x28)
+ *     24 - s11 (x27)
+ *     23 - s10 (x26)
+ *     22 - s9 (x25)
+ *     21 - s8 (x24)
+ *     20 - s7 (x23)
+ *     19 - s6 (x22)
+ *     18 - s5 (x21)
+ *     17 - s4 (x20)
+ *     16 - s3 (x19)
+ *     15 - s2 (x18)
+ *     14 - a7 (x17)
+ *     13 - a6 (x16)
+ *     12 - a5 (x15)
+ *     11 - a4 (x14)
+ *     10 - a3 (x13)
+ *      9 - a2 (x12)
+ *      8 - a1 (x11)
+ *      7 - (pvParameters)
+ *      6 - s1 (x9)
+ *      5 - s0 (x8)
+ *      4 - t2 (x7)
+ *      3 - t1 (x6)
+ *      2 - t0 (x5)
+ *      1 - (return address for main task, 0 for now)
+ *      0 - (pxCode)
+ * -----Stack Top----
+ */
+.balign 8
+.global pxPortInitialiseStack
+.type pxPortInitialiseStack, @function
+pxPortInitialiseStack:
+
+  // Setup the MSTATUS register.
+  csrr t0, mstatus
+  // Ensure interrupts are disabled when the stack is restored within an ISR.
+  // Required when a task is created after the scheduler has been started,
+  // otherwise interrupts would be disabled anyway.
+  andi t0, t0, ~0x8
+  // Generate the value 0x1880, to set the MPIE and MPP bits in MSTATUS.
+  li t1, 0x188 << 4
+  or t0, t0, t1
+
+  // Setup the stack frame detailed above (a0 holds the task stack pointer).
+  addi a0, a0, -PORT_CONTEXT_SIZE
+  // Push MSTATUS onto the stack.
+  sw t0, 29(a0)
+  // Push task parameters (pvParameters that is in x12/a2, on the stack.
+  sw a2, 7(a0)
+  // Push 0 for the portTASK_RETURN_ADDRESS for now.
+  sw zero, 1(a0)
+  // Push the pointer to the task's entry point (pxCode) onto the stack. This
+  // will be loaded into either ra (in xPortStartFirstTask) or mepc (in
+  // freertosIrqExit), so that when ret/mret is called control flow will be
+  // transferred accordingly.
+  sw a1, 0(a0)
+
+  ret
+
+  // Set size so this function can be disassembled.
+  .size pxPortInitialiseStack, .-pxPortInitialiseStack
diff --git a/sw/device/lib/testing/test_framework/freertos_port.c b/sw/device/lib/testing/test_framework/freertos_port.c
new file mode 100644
index 0000000..f5f155c
--- /dev/null
+++ b/sw/device/lib/testing/test_framework/freertos_port.c
@@ -0,0 +1,102 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+
+#include "sw/device/lib/base/csr.h"
+#include "sw/device/lib/base/memory.h"
+#include "sw/device/lib/dif/dif_rv_timer.h"
+#include "sw/device/lib/handler.h"
+#include "sw/device/lib/irq.h"
+#include "sw/device/lib/runtime/log.h"
+#include "sw/device/lib/testing/check.h"
+#include "sw/vendor/freertos_freertos_kernel/include/FreeRTOS.h"
+#include "sw/vendor/freertos_freertos_kernel/include/task.h"
+#include "sw/vendor/freertos_freertos_kernel/portable/GCC/RISC-V/portmacro.h"
+
+// TODO: make this toplevel agnostic.
+#include "hw/top_earlgrey/sw/autogen/top_earlgrey.h"  // Generated.
+
+// NOTE: some of the function names below do NOT, and cannot, conform to the
+// style guide, since they are specific implementations of FreeRTOS defined
+// functions.
+
+// ----------------------------------------------------------------------------
+// Timer.
+// ----------------------------------------------------------------------------
+static dif_rv_timer_t timer;
+static const uint32_t kTimerHartId = (uint32_t)kTopEarlgreyPlicTargetIbex0;
+static const uint32_t kTimerComparatorId = 0;
+static const uint64_t kTimerDeadline =
+    100;  // Counter must reach 100 for an IRQ to be triggered.
+
+void vPortSetupTimerInterrupt(void) {
+  LOG_INFO("Configuring timer interrupt ...");
+
+  // Initialize and reset the timer.
+  mmio_region_t timer_reg =
+      mmio_region_from_addr(TOP_EARLGREY_RV_TIMER_BASE_ADDR);
+  CHECK(dif_rv_timer_init(
+            timer_reg,
+            (dif_rv_timer_config_t){.hart_count = 1, .comparator_count = 1},
+            &timer) == kDifRvTimerOk);
+
+  // Compute and set tick parameters (i.e., step, prescale, etc.).
+  dif_rv_timer_tick_params_t tick_params;
+  CHECK(dif_rv_timer_approximate_tick_params(
+            kClockFreqPeripheralHz, configTICK_RATE_HZ * kTimerDeadline,
+            &tick_params) == kDifRvTimerApproximateTickParamsOk);
+  LOG_INFO("Tick Freq. (Hz): %u, Prescale: %u; Tick Step: %u",
+           (uint32_t)kClockFreqPeripheralHz, (uint32_t)tick_params.prescale,
+           (uint32_t)tick_params.tick_step);
+  CHECK(dif_rv_timer_set_tick_params(&timer, kTimerHartId, tick_params) ==
+        kDifRvTimerOk);
+
+  // Enable RV Timer interrupts and arm/enable the timer.
+  CHECK(dif_rv_timer_irq_enable(&timer, kTimerHartId, kTimerComparatorId,
+                                kDifRvTimerEnabled) == kDifRvTimerOk);
+  CHECK(dif_rv_timer_arm(&timer, kTimerHartId, kTimerComparatorId,
+                         kTimerDeadline) == kDifRvTimerOk);
+
+  CHECK(dif_rv_timer_counter_set_enabled(&timer, kTimerHartId,
+                                         kDifRvTimerEnabled) == kDifRvTimerOk);
+}
+
+// ----------------------------------------------------------------------------
+// Scheduler.
+// ----------------------------------------------------------------------------
+extern void xPortStartFirstTask(void);
+
+BaseType_t xPortStartScheduler(void) {
+  vPortSetupTimerInterrupt();
+  irq_timer_ctrl(true);
+  irq_external_ctrl(true);
+  irq_software_ctrl(true);
+  xPortStartFirstTask();
+
+  // Unreachable.
+  return pdFAIL;
+}
+
+void vPortEndScheduler(void) {
+  // Not implemented.
+  // TODO: trigger this to be called when from the idle task hook when all tests
+  // have completed.
+  while (true) {
+    wait_for_interrupt();
+  }
+}
+
+// ----------------------------------------------------------------------------
+// ISRs.
+// TODO: add support for remaining ISRs.
+// ----------------------------------------------------------------------------
+void handler_irq_timer(void) {
+  LOG_INFO("Handling timer IQR ...");
+  CHECK(dif_rv_timer_irq_disable(&timer, kTimerHartId, NULL) == kDifRvTimerOk);
+  CHECK(dif_rv_timer_counter_write(&timer, kTimerHartId, 0) == kDifRvTimerOk);
+  CHECK(dif_rv_timer_irq_clear(&timer, kTimerHartId, kTimerComparatorId) ==
+        kDifRvTimerOk);
+  CHECK(dif_rv_timer_irq_enable(&timer, kTimerHartId, kTimerComparatorId,
+                                kDifRvTimerEnabled) == kDifRvTimerOk);
+  LOG_INFO("Done.");
+}
diff --git a/sw/device/lib/testing/test_framework/meson.build b/sw/device/lib/testing/test_framework/meson.build
index 78e3c3b..b3b1092 100644
--- a/sw/device/lib/testing/test_framework/meson.build
+++ b/sw/device/lib/testing/test_framework/meson.build
@@ -2,6 +2,9 @@
 # Licensed under the Apache License, Version 2.0, see LICENSE for details.
 # SPDX-License-Identifier: Apache-2.0
 
+###############################################################################
+# (Current) On-device Test Framework
+###############################################################################
 # Test status library.
 sw_lib_testing_test_status = declare_dependency(
   link_with: static_library(
@@ -62,3 +65,52 @@
     ],
   )
 )
+
+###############################################################################
+# (Future) On-device Test Framework (OTTF)
+# See #8015: https://github.com/lowRISC/opentitan/issues/8015
+###############################################################################
+# FreeRTOS kernel paths.
+freertos_root = '@0@/@1@'.format(meson.source_root(), 'sw/vendor/freertos_freertos_kernel')
+freertos_memmang_path = '@0@/@1@'.format(freertos_root, 'portable/MemMang')
+freertos_portable_path = '@0@/@1@'.format(freertos_root, 'portable/GCC/RISC-V')
+
+# OpenTitan Test Framework (OTTF) sources & includes.
+ottf_sources = [
+  'ottf.c',
+  'example_earlgrey_test.c',
+  'freertos_hooks.c',
+  'freertos_port.S',
+  'freertos_port.c',
+  hw_ip_rv_timer_reg_h,
+  join_paths(freertos_root ,'tasks.c'),
+  join_paths(freertos_root ,'queue.c'),
+  join_paths(freertos_root ,'list.c'),
+  join_paths(freertos_memmang_path,'heap_1.c'),
+]
+ottf_incdirs = include_directories(
+  '../../../../vendor/freertos_freertos_kernel/include',
+  '../../../../vendor/freertos_freertos_kernel/portable/GCC/RISC-V')
+
+# OpenTitan Test Framework (OTTF)
+sw_lib_ottf = declare_dependency(
+  link_with: static_library(
+    'sw_lib_ottf',
+    sources: ottf_sources,
+    include_directories: ottf_incdirs,
+    c_args: [
+      '-D__riscv_float_abi_soft',
+    ],
+    dependencies: [
+      sw_lib_mem,
+      sw_lib_runtime_hart,
+      sw_lib_runtime_log,
+      sw_lib_runtime_print,
+      sw_lib_irq,
+      sw_lib_dif_uart,
+      sw_lib_dif_rv_timer,
+      sw_lib_testing_test_status,
+      sw_lib_testing_test_coverage,
+    ],
+  )
+)
diff --git a/sw/device/lib/testing/test_framework/ottf.c b/sw/device/lib/testing/test_framework/ottf.c
new file mode 100644
index 0000000..1071b86
--- /dev/null
+++ b/sw/device/lib/testing/test_framework/ottf.c
@@ -0,0 +1,66 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+
+#include "sw/device/lib/testing/test_framework/ottf.h"
+
+#include "sw/device/lib/arch/device.h"
+#include "sw/device/lib/dif/dif_uart.h"
+#include "sw/device/lib/runtime/log.h"
+#include "sw/device/lib/runtime/print.h"
+#include "sw/device/lib/testing/check.h"
+#include "sw/device/lib/testing/test_framework/FreeRTOSConfig.h"
+#include "sw/device/lib/testing/test_framework/test_coverage.h"
+#include "sw/device/lib/testing/test_framework/test_status.h"
+#include "sw/vendor/freertos_freertos_kernel/include/FreeRTOS.h"
+#include "sw/vendor/freertos_freertos_kernel/include/queue.h"
+#include "sw/vendor/freertos_freertos_kernel/include/task.h"
+
+// TODO: make this toplevel agnostic.
+#include "hw/top_earlgrey/sw/autogen/top_earlgrey.h"
+
+// UART for communication with host.
+static dif_uart_t uart0;
+
+static void init_uart(void) {
+  CHECK(dif_uart_init(mmio_region_from_addr(TOP_EARLGREY_UART0_BASE_ADDR),
+                      &uart0) == kDifOk,
+        "failed to init UART");
+  CHECK(dif_uart_configure(&uart0,
+                           (dif_uart_config_t){
+                               .baudrate = kUartBaudrate,
+                               .clk_freq_hz = kClockFreqPeripheralHz,
+                               .parity_enable = kDifToggleDisabled,
+                               .parity = kDifUartParityEven,
+                           }) == kDifUartConfigOk,
+        "failed to configure UART");
+  base_uart_stdout(&uart0);
+}
+
+int main(int argc, char **argv) {
+  test_status_set(kTestStatusInTest);
+
+  // Initialize the UART to enable logging for non-DV simulation platforms.
+  if (kDeviceType != kDeviceSimDV) {
+    init_uart();
+  }
+
+  // Run the test, which is contained within `test_main()`, as a FreeRTOS task.
+  bool result = false;
+  LOG_INFO("Starting test (%s) in a FreeRTOS task ...", kTestConfig.test_name);
+  TaskHandle_t test_task_handle = NULL;
+  xTaskCreate(test_main, kTestConfig.test_name, configMINIMAL_STACK_SIZE,
+              &result, tskIDLE_PRIORITY + 1, &test_task_handle);
+  vTaskStartScheduler();
+
+  // Must happen before any debug output.
+  if (kTestConfig.can_clobber_uart) {
+    init_uart();
+  }
+
+  test_coverage_send_buffer();
+  test_status_set(result ? kTestStatusPassed : kTestStatusFailed);
+
+  // Unreachable code.
+  return 1;
+}
diff --git a/sw/device/lib/testing/test_framework/ottf.h b/sw/device/lib/testing/test_framework/ottf.h
new file mode 100644
index 0000000..343af09
--- /dev/null
+++ b/sw/device/lib/testing/test_framework/ottf.h
@@ -0,0 +1,68 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+
+#ifndef OPENTITAN_SW_DEVICE_LIB_TESTING_TEST_FRAMEWORK_OTTF_H_
+#define OPENTITAN_SW_DEVICE_LIB_TESTING_TEST_FRAMEWORK_OTTF_H_
+
+#include <stdbool.h>
+
+#include "sw/device/lib/testing/test_framework/FreeRTOSConfig.h"
+
+/**
+ * @file
+ * @brief Entrypoint definitions for on-device tests
+ */
+
+/**
+ * Configuration variables for an on-device test.
+ *
+ * This type represents configuration values for an on-device test, which allow
+ * tests to configure the behavior of the OpenTitan Test Framework (OTTF).
+ *
+ * New fields can be safely added to this struct without affecting any tests;
+ * the "default" value of all fields should be zero (or NULL, or equivalent).
+ *
+ * See `kTestConfig`.
+ */
+typedef struct test_config {
+  /**
+   * Indicates that `test_main()` does something non-trivial to the UART
+   * device. Setting this to true will make `test_main()` guard against this
+   * by resetting the UART device before printing debug information.
+   */
+  bool can_clobber_uart;
+  /**
+   * A short name for the test for debugging purposes within FreeRTOS.
+   */
+  char test_name[configMAX_TASK_NAME_LEN];
+} test_config_t;
+
+/**
+ * Global test configuration.
+ *
+ * This symbol should be defined externally in a standalone SW test. For most
+ * tests, this will just look like the following:
+ *
+ *   const test_config_t kTestConfig;
+ *
+ * The zero values of all of the fields will behave like sane defaults.
+ *
+ * This value needs to be provided as a global so that the initialization code
+ * that runs before `test_main()` is executed can take note of it.
+ */
+extern const test_config_t kTestConfig;
+
+/**
+ * Entry point for a SW on-device (or chip-level) test.
+ *
+ * This function should be defined externally in a standalone SW test, linked
+ * together with this library. This library provides a `main()` function that
+ * does test harness setup, initializes FreeRTOS, and starts a FreeRTOS task
+ * that executes `test_main()`.
+ *
+ * @return success or failure of the test as boolean.
+ */
+extern void test_main(void *pvParameters);
+
+#endif  // OPENTITAN_SW_DEVICE_LIB_TESTING_TEST_FRAMEWORK_OTTF_H_
diff --git a/sw/device/tests/meson.build b/sw/device/tests/meson.build
index 1b1c8ae..d9260ca 100644
--- a/sw/device/tests/meson.build
+++ b/sw/device/tests/meson.build
@@ -796,3 +796,59 @@
     build_by_default: true,
   )
 endforeach
+
+# OpenTitan (FreeRTOS) Test Framework
+foreach device_name, device_lib : sw_lib_arch_core_devices
+  ottf_elf = executable(
+    'ottf_' + device_name,
+    name_suffix: 'elf',
+    dependencies: [
+      riscv_crt,
+      device_lib,
+      sw_lib_ottf,
+      sw_lib_irq_handlers,
+    ],
+  )
+
+  target_name = 'ottf_@0@_' + device_name
+
+  ottf_dis = custom_target(
+    target_name.format('dis'),
+    input: ottf_elf,
+    kwargs: elf_to_dis_custom_target_args,
+  )
+
+  ottf_bin = custom_target(
+    target_name.format('bin'),
+    input: ottf_elf,
+    kwargs: elf_to_bin_custom_target_args,
+  )
+
+  ottf_vmem32 = custom_target(
+    target_name.format('vmem32'),
+    input: ottf_bin,
+    kwargs: bin_to_vmem32_custom_target_args,
+  )
+
+  ottf_vmem64 = custom_target(
+    target_name.format('vmem64'),
+    input: ottf_bin,
+    kwargs: bin_to_vmem64_custom_target_args,
+  )
+
+  custom_target(
+    target_name.format('export'),
+    command: export_target_command,
+    depend_files: [export_target_depend_files,],
+    input: [
+      ottf_elf,
+      ottf_dis,
+      ottf_bin,
+      ottf_vmem32,
+      ottf_vmem64,
+    ],
+    output: target_name.format('export'),
+    build_always_stale: true,
+    build_by_default: true,
+  )
+endforeach