[CI] Add bare-metal RISCV 64 sample and QEMU smoke test (#24844)

Adds `samples/baremetal_riscv64`, a single-op inference runner for
bare-metal RISCV 64 under `qemu-system-riscv64` (`-machine virt`, no OS,
semihosted I/O), together with a pkgci smoke test.

The sample covers two execution paths:
- `llvm-cpu` using `inline-dynamic` and the embedded-ELF loader.
- `vmvx-inline` using `inline-static`.

The runner uses the inline HAL instead of the full-HAL implementation
following `samples/simple_embedding`, because HAL device creation
currently requires a proactor that `IREE_PLATFORM_GENERIC` does not
provide.

The runtime is compiled and linked using an xPack GCC/newlib sysroot.
Its newlib is built with `-mcmodel=medany` required for QEMUs RAM base
and provides `semihost.specs` for semihosted I/O and exit handling.

No HAL device drivers are enabled; the embedded-ELF loader is enabled
explicitly for the LLVMCPU runner. The sample is `EXCLUDE_FROM_ALL` and
CI builds only `samples/baremetal_riscv64/all`, so it does not affect
other Generic RISCV builds.


Assisted-by: Claude Code

Signed-off-by: Pooja Hemashekar <hemashekar@roofline.ai>
diff --git a/.github/workflows/pkgci_test_riscv64.yml b/.github/workflows/pkgci_test_riscv64.yml
index 1d99c8d..0583df6 100644
--- a/.github/workflows/pkgci_test_riscv64.yml
+++ b/.github/workflows/pkgci_test_riscv64.yml
@@ -92,3 +92,77 @@
         env:
           QEMU_BIN: ${{ env.QEMU_PATH_PREFIX }}/qemu-riscv64
         run: ./build_tools/cmake/test_riscv.sh
+
+  riscv64-baremetal:
+    runs-on: ubuntu-24.04
+    env:
+      PACKAGE_DOWNLOAD_DIR: ${{ github.workspace }}/.packages
+      VENV_DIR: ${{ github.workspace }}/.venv
+      GH_TOKEN: ${{ github.token }}
+      RISCV_TOOLCHAIN_ROOT: ${{ github.workspace }}/riscv/xpack-gcc
+      # xPack riscv-none-elf gcc: newlib is built -mcmodel=medany and ships
+      # semihost.specs, as samples/baremetal_riscv64 requires.
+      XPACK_GCC_VERSION: "15.2.0-1"
+      XPACK_GCC_SHA256: "aaaa8060c914851a3e5ee1ba82cc3d6f80972f90638a05c6e823a37557a33758"
+    steps:
+      # General setup.
+      - name: "Checking out repository"
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+      - name: "Checking out runtime submodules"
+        run: ./build_tools/scripts/git/update_runtime_submodules.sh
+      - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+        with:
+          # Must match the subset of versions built in pkgci_build_packages.
+          python-version: "3.11"
+      - name: ccache
+        uses: hendrikmuhs/ccache-action@d62db5f07c26379fc4b4e0916f098a92573c3b03 # v1.2.23
+        with:
+          key: ${{ github.job }}
+          save: ${{ inputs.write-caches == 1 }}
+      - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+        if: ${{ inputs.artifact_run_id == '' }}
+        with:
+          name: linux_x86_64_release_packages
+          path: ${{ env.PACKAGE_DOWNLOAD_DIR }}
+      - name: Install build dependencies
+        run: sudo apt update && sudo apt install -y ninja-build qemu-system-misc
+      - name: Setup base venv
+        run: |
+          ./build_tools/pkgci/setup_venv.py ${VENV_DIR} \
+            --artifact-path=${PACKAGE_DOWNLOAD_DIR} \
+            --fetch-gh-workflow=${{ inputs.artifact_run_id }}
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+      - name: Restore toolchain cache
+        id: restore-toolchain
+        uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+        with:
+          path: riscv
+          key: riscv-baremetal-toolchain-xpack-${{ env.XPACK_GCC_VERSION }}-${{ runner.arch }}
+      - name: Bootstrap toolchain
+        if: ${{ steps.restore-toolchain.outputs.cache-hit != 'true' }}
+        run: |
+          TARBALL="xpack-riscv-none-elf-gcc-${XPACK_GCC_VERSION}-linux-x64.tar.gz"
+          wget -q "https://github.com/xpack-dev-tools/riscv-none-elf-gcc-xpack/releases/download/v${XPACK_GCC_VERSION}/${TARBALL}"
+          echo "${XPACK_GCC_SHA256}  ${TARBALL}" | sha256sum -c -
+          mkdir -p "${RISCV_TOOLCHAIN_ROOT}"
+          tar -xzf "${TARBALL}" -C "${RISCV_TOOLCHAIN_ROOT}" --no-same-owner --strip-components=1
+          rm "${TARBALL}"
+      - name: Save toolchain cache
+        if: ${{ inputs.write-caches == 1 && steps.restore-toolchain.outputs.cache-hit != 'true' }}
+        uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+        with:
+          path: riscv
+          key: riscv-baremetal-toolchain-xpack-${{ env.XPACK_GCC_VERSION }}-${{ runner.arch }}
+      - name: Build riscv64 bare-metal runtime
+        env:
+          IREE_HOST_BIN_DIR: ${{ env.VENV_DIR }}/bin
+          IREE_READ_REMOTE_CCACHE: 0
+          IREE_WRITE_REMOTE_CCACHE: 0
+          IREE_READ_LOCAL_CCACHE: 1
+          IREE_WRITE_LOCAL_CCACHE: ${{ inputs.write-caches }}
+        run: ./build_tools/cmake/build_riscv_baremetal.sh
+      - name: Run bare-metal smoke tests on QEMU
+        env:
+          QEMU_BIN: qemu-system-riscv64
+        run: ./samples/baremetal_riscv64/run_qemu.sh
diff --git a/build_tools/cmake/build_riscv_baremetal.sh b/build_tools/cmake/build_riscv_baremetal.sh
new file mode 100755
index 0000000..683bdf0
--- /dev/null
+++ b/build_tools/cmake/build_riscv_baremetal.sh
@@ -0,0 +1,66 @@
+#!/bin/bash
+
+# Copyright 2026 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+# Cross-compile the runtime and the samples/baremetal_riscv64 runners for
+# bare-metal RISC-V 64 using build_tools/cmake/generic_riscv64_gcc.cmake.
+#
+# Requires RISCV_TOOLCHAIN_ROOT to point to a riscv-none-elf GCC toolchain
+# whose newlib was built with -mcmodel=medany and that provides
+# semihost.specs, such as xPack riscv-none-elf-gcc.
+#
+# IREE_HOST_BIN_DIR specifies the directory containing the prebuilt IREE host
+# tools and defaults to "build/install/bin".
+#
+# Pass the desired build directory as the first argument. Otherwise,
+# IREE_TARGET_BUILD_DIR is used and defaults to "build-riscv-baremetal".
+#
+# This script is designed for CI but can also be run manually. It must be run
+# from the IREE repository root.
+
+set -xeuo pipefail
+
+BUILD_DIR="${1:-${IREE_TARGET_BUILD_DIR:-build-riscv-baremetal}}"
+CMAKE_BUILD_TYPE="${CMAKE_BUILD_TYPE:-RelWithDebInfo}"
+IREE_ENABLE_ASSERTIONS="${IREE_ENABLE_ASSERTIONS:-ON}"
+IREE_HOST_BIN_DIR="$(realpath "${IREE_HOST_BIN_DIR:-build/install/bin}")"
+
+source build_tools/cmake/setup_build.sh
+source build_tools/cmake/setup_ccache.sh
+
+declare -a args
+args=(
+  "-G" "Ninja"
+  "-B" "${BUILD_DIR}"
+
+  "-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}"
+  "-DIREE_ENABLE_ASSERTIONS=${IREE_ENABLE_ASSERTIONS}"
+  "-DPython3_EXECUTABLE=${IREE_PYTHON3_EXECUTABLE}"
+
+  # Cross compiling bare-metal RISC-V.
+  "-DCMAKE_TOOLCHAIN_FILE=$(realpath build_tools/cmake/generic_riscv64_gcc.cmake)"
+  "-DRISCV_TOOLCHAIN_ROOT=${RISCV_TOOLCHAIN_ROOT}"
+  "-DIREE_BUILD_COMPILER=OFF"
+  "-DIREE_BUILD_SAMPLES=ON"
+  "-DIREE_HOST_BIN_DIR=${IREE_HOST_BIN_DIR}"
+
+  # No HAL device drivers: the sample uses the inline HAL, which runs the
+  # module without creating a HAL device. The embedded-ELF loader (used by
+  # the hal_loader module for llvm-cpu kernels) must be enabled explicitly
+  # because loaders default off when no local driver is enabled.
+  "-DIREE_HAL_DRIVER_DEFAULTS=OFF"
+  "-DIREE_HAL_EXECUTABLE_LOADER_EMBEDDED_ELF=ON"
+)
+
+"${CMAKE_BIN}" "${args[@]}"
+# Build only the bare-metal sample and the runtime targets it depends on.
+# Other sample targets may not support this GCC/newlib configuration.
+"${CMAKE_BIN}" --build "${BUILD_DIR}" --target samples/baremetal_riscv64/all -- -k 0
+
+if (( IREE_USE_CCACHE == 1 )); then
+  ccache --show-stats
+fi
diff --git a/build_tools/cmake/generic_riscv64_gcc.cmake b/build_tools/cmake/generic_riscv64_gcc.cmake
new file mode 100644
index 0000000..5e7769c
--- /dev/null
+++ b/build_tools/cmake/generic_riscv64_gcc.cmake
@@ -0,0 +1,61 @@
+# Copyright 2026 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+# Toolchain file for bare-metal riscv-none-elf GCC/newlib cross-compilation,
+# using one sysroot for both compilation and linking.
+#
+# samples/baremetal_riscv64 requires newlib to be built with -mcmodel=medany
+# because QEMU's virt machine places RAM at 0x80000000. The toolchain must also
+# provide semihost.specs for console I/O and process exit.
+
+cmake_minimum_required(VERSION 3.26)
+
+# CMake invokes the toolchain file twice during the first build, but only once
+# during subsequent rebuilds. This was causing the various flags to be added
+# twice on the first build, and on a rebuild ninja would see only one set of
+# the flags and rebuild the world.
+# https://github.com/android-ndk/ndk/issues/323
+if(RISCV_TOOLCHAIN_INCLUDED)
+  return()
+endif()
+set(RISCV_TOOLCHAIN_INCLUDED true)
+
+set(CMAKE_SYSTEM_NAME Generic)
+set(CMAKE_SYSTEM_PROCESSOR riscv64)
+
+if(NOT "${RISCV_TOOLCHAIN_ROOT}" STREQUAL "")
+  set(CMAKE_AR           "${RISCV_TOOLCHAIN_ROOT}/bin/riscv-none-elf-ar")
+  set(CMAKE_C_COMPILER   "${RISCV_TOOLCHAIN_ROOT}/bin/riscv-none-elf-gcc")
+  set(CMAKE_CXX_COMPILER "${RISCV_TOOLCHAIN_ROOT}/bin/riscv-none-elf-g++")
+  set(CMAKE_RANLIB       "${RISCV_TOOLCHAIN_ROOT}/bin/riscv-none-elf-ranlib")
+  set(CMAKE_STRIP        "${RISCV_TOOLCHAIN_ROOT}/bin/riscv-none-elf-strip")
+endif()
+
+# Bare-metal: configure-time link checks have no crt0/syscalls.
+set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)
+
+set(CMAKE_C_EXTENSIONS OFF CACHE BOOL "" FORCE) # gnu17 selects a clock_nanosleep() path newlib lacks.
+set(IREE_BUILD_TESTS OFF CACHE BOOL "" FORCE)
+set(IREE_ENABLE_POSIX OFF CACHE BOOL "" FORCE)
+set(IREE_ENABLE_THREADING OFF CACHE BOOL "" FORCE)
+set(IREE_SYNCHRONIZATION_DISABLE_UNSAFE ON CACHE BOOL "" FORCE)
+set(IREE_HAL_DRIVER_LOCAL_TASK OFF CACHE BOOL "" FORCE)
+set(IREE_HAL_EXECUTABLE_LOADER_SYSTEM_LIBRARY OFF CACHE BOOL "" FORCE)
+set(IREE_HAL_EXECUTABLE_PLUGIN_SYSTEM_LIBRARY OFF CACHE BOOL "" FORCE)
+
+# The default RISC-V ISA specification treats zicsr and zifencei as
+# separate extensions, so name them explicitly.
+set(RISCV_COMPILER_FLAGS "\
+    -march=rv64imafdc_zicsr_zifencei -mabi=lp64d -mcmodel=medany \
+    -DIREE_PLATFORM_GENERIC=1 \
+    -DIREE_FILE_IO_ENABLE=0 -DIREE_TIME_NOW_FN=\"\{ return 0; \}\" -DIREE_DEVICE_SIZE_T=uint64_t -DPRIdsz=PRIu64")
+
+set(CMAKE_C_FLAGS_INIT   "${RISCV_COMPILER_FLAGS}")
+set(CMAKE_CXX_FLAGS_INIT "${RISCV_COMPILER_FLAGS}")
+set(CMAKE_ASM_FLAGS_INIT "${RISCV_COMPILER_FLAGS}")
+
+# GNUInstallDirs does not set CMAKE_INSTALL_LIBDIR in this configuration.
+set(CMAKE_INSTALL_LIBDIR "lib" CACHE PATH "")
diff --git a/samples/CMakeLists.txt b/samples/CMakeLists.txt
index 0dfdc27..fe5cfa2 100644
--- a/samples/CMakeLists.txt
+++ b/samples/CMakeLists.txt
@@ -12,6 +12,9 @@
 # included:
 #   compiler_plugins
 
+# Not in the default build: needs a riscv-none-elf GCC toolchain with
+# semihosting support and an explicit --target samples/baremetal_riscv64/all.
+add_subdirectory(baremetal_riscv64 EXCLUDE_FROM_ALL)
 add_subdirectory(custom_dispatch)
 add_subdirectory(custom_module)
 add_subdirectory(dynamic_shapes)
diff --git a/samples/baremetal_riscv64/CMakeLists.txt b/samples/baremetal_riscv64/CMakeLists.txt
new file mode 100644
index 0000000..9fef406
--- /dev/null
+++ b/samples/baremetal_riscv64/CMakeLists.txt
@@ -0,0 +1,110 @@
+# Copyright 2026 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+# Bare-metal riscv64 single-op inference runners for QEMU's virt machine.
+# Requires a riscv-none-elf GCC cross build (build_tools/cmake/generic_riscv64_gcc.cmake)
+# and host tools; see build_tools/cmake/build_riscv_baremetal.sh
+
+if(NOT CMAKE_SYSTEM_NAME STREQUAL "Generic" OR
+   NOT CMAKE_SYSTEM_PROCESSOR STREQUAL "riscv64" OR
+   NOT IREE_HOST_BIN_DIR)
+  return()
+endif()
+
+# The link options below rely on GCC spec files (semihost.specs).
+if(NOT CMAKE_C_COMPILER_ID STREQUAL "GNU")
+  message(STATUS
+      "Skipping samples/baremetal_riscv64: requires a riscv-none-elf GCC "
+      "toolchain with semihosting support (CMAKE_C_COMPILER_ID is "
+      "${CMAKE_C_COMPILER_ID})")
+  return()
+endif()
+
+# QEMU virt: RAM starts at 0x80000000. start.S sets the stack pointer to
+# 0x88000000 (128 MiB above the RAM base) and the stack grows downward.
+set(_BAREMETAL_QEMU_VIRT_LINKOPTS
+  "--specs=semihost.specs"
+  "-Wl,-Ttext-segment=0x80000000"
+  "-Wl,--defsym=__stack_top=0x88000000"
+  "-Wl,-e,_enter"
+)
+
+iree_bytecode_module(
+  NAME
+    simple_mul_module_llvmcpu
+  SRC
+    "simple_mul.mlir"
+  C_IDENTIFIER
+    "iree_samples_baremetal_riscv64_module_llvmcpu"
+  FLAGS
+    "--iree-execution-model=inline-dynamic"
+    "--iree-hal-target-device=local"
+    "--iree-hal-local-target-device-backends=llvm-cpu"
+    "--iree-llvmcpu-target-triple=riscv64-unknown-unknown-elf"
+    "--iree-llvmcpu-target-cpu-features=+m,+a,+f,+d,+c"
+    "--iree-llvmcpu-target-abi=lp64d"
+    "--iree-llvmcpu-debug-symbols=false"
+    "--iree-vm-bytecode-module-strip-source-map=true"
+    "--iree-vm-emit-polyglot-zip=false"
+  PUBLIC
+)
+
+iree_bytecode_module(
+  NAME
+    simple_mul_module_vmvx
+  SRC
+    "simple_mul.mlir"
+  C_IDENTIFIER
+    "iree_samples_baremetal_riscv64_module_vmvx"
+  FLAGS
+    "--iree-execution-model=inline-static"
+    "--iree-hal-target-device=local"
+    "--iree-hal-local-target-device-backends=vmvx-inline"
+    "--iree-llvmcpu-debug-symbols=false"
+    "--iree-vm-bytecode-module-strip-source-map=true"
+    "--iree-vm-emit-polyglot-zip=false"
+  PUBLIC
+)
+
+iree_cc_binary(
+  NAME
+    runner_llvmcpu
+  SRCS
+    "runner.c"
+    "start.S"
+  DEFINES
+    "USE_LLVMCPU=1"
+  DEPS
+    ::simple_mul_module_llvmcpu_c
+    iree::base
+    iree::hal
+    iree::hal::local::loaders::embedded_elf_loader
+    iree::modules::hal::inline
+    iree::modules::hal::loader
+    iree::modules::hal::types
+    iree::vm
+    iree::vm::bytecode::module
+  LINKOPTS
+    ${_BAREMETAL_QEMU_VIRT_LINKOPTS}
+)
+
+iree_cc_binary(
+  NAME
+    runner_vmvx
+  SRCS
+    "runner.c"
+    "start.S"
+  DEPS
+    ::simple_mul_module_vmvx_c
+    iree::base
+    iree::hal
+    iree::modules::hal::inline
+    iree::modules::hal::types
+    iree::vm
+    iree::vm::bytecode::module
+  LINKOPTS
+    ${_BAREMETAL_QEMU_VIRT_LINKOPTS}
+)
diff --git a/samples/baremetal_riscv64/README.md b/samples/baremetal_riscv64/README.md
new file mode 100644
index 0000000..a2433fe
--- /dev/null
+++ b/samples/baremetal_riscv64/README.md
@@ -0,0 +1,41 @@
+# Bare-metal RISC-V 64 sample
+
+Runs a single-op IREE workload (elementwise `arith.mulf` on `4xf32`) on
+bare-metal riscv64 under `qemu-system-riscv64` (`-machine virt`, no OS,
+semihosted I/O), using the inline HAL. Two runners are built:
+
+- `runner_llvmcpu`: kernels compiled to RISC-V machine code by the `llvm-cpu`
+  backend (`--iree-execution-model=inline-dynamic`), loaded at runtime with
+  the embedded-ELF loader via the `hal_loader` VM module.
+- `runner_vmvx`: kernels as portable VM bytecode (`vmvx-inline`,
+  `--iree-execution-model=inline-static`); no loader involved.
+
+The full-HAL path used by `samples/simple_embedding` is not used here because
+its synchronous device creation currently requires a proactor, which
+`IREE_PLATFORM_GENERIC` does not provide. The inline HAL runs the module
+without a HAL device, threads, or a filesystem.
+
+## Building and running
+
+Requires a `riscv-none-elf` GCC toolchain that provides `semihost.specs` and
+uses a newlib built with `-mcmodel=medany`, such as
+[xPack riscv-none-elf-gcc](https://github.com/xpack-dev-tools/riscv-none-elf-gcc-xpack),
+along with prebuilt IREE host tools and `qemu-system-riscv64`. From the repository
+root:
+
+```sh
+RISCV_TOOLCHAIN_ROOT=/path/to/xpack-gcc \
+IREE_HOST_BIN_DIR=/path/to/host/tools \
+  ./build_tools/cmake/build_riscv_baremetal.sh
+
+./samples/baremetal_riscv64/run_qemu.sh
+```
+
+## Boot and memory layout
+
+QEMU's `virt` machine places RAM at `0x80000000`, requiring
+`-mcmodel=medany` throughout, including for newlib. QEMU's generic loader
+loads the ELF and sets CPU 0's PC to its entry point, but does not initialize
+a stack. `start.S` sets `sp` to `__stack_top`—128 MiB above the RAM base—and
+enables the FPU before tailing into newlib's `crt0`. Semihosting provides
+console output and process exit.
diff --git a/samples/baremetal_riscv64/run_qemu.sh b/samples/baremetal_riscv64/run_qemu.sh
new file mode 100755
index 0000000..2245c2d
--- /dev/null
+++ b/samples/baremetal_riscv64/run_qemu.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+# Copyright 2026 The IREE Authors
+#
+# Licensed under the Apache License v2.0 with LLVM Exceptions.
+# See https://llvm.org/LICENSE.txt for license information.
+# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+# Runs the baremetal_riscv64 sample runners on qemu-system-riscv64
+# (-machine virt, no OS, semihosted I/O). The runners are built by
+# build_tools/cmake/build_riscv_baremetal.sh.
+#
+# Environment:
+#   QEMU_BIN              qemu-system-riscv64 binary (default: from PATH).
+#   IREE_TARGET_BUILD_DIR build from build_riscv_baremetal.sh
+#                         (default: build-riscv-baremetal).
+
+set -xeuo pipefail
+
+QEMU_BIN="${QEMU_BIN:-qemu-system-riscv64}"
+IREE_TARGET_BUILD_DIR="${IREE_TARGET_BUILD_DIR:-build-riscv-baremetal}"
+SAMPLE_DIR="${IREE_TARGET_BUILD_DIR}/samples/baremetal_riscv64"
+
+for runner in runner_llvmcpu runner_vmvx; do
+  timeout 120 "${QEMU_BIN}" -machine virt -m 512M -nographic -semihosting \
+    -bios none \
+    -device loader,file="${SAMPLE_DIR}/${runner}",cpu-num=0 \
+    2>&1 | tee "${SAMPLE_DIR}/${runner}.log"
+  grep -q "PASS: single-op inference on bare-metal riscv64" \
+    "${SAMPLE_DIR}/${runner}.log"
+done
diff --git a/samples/baremetal_riscv64/runner.c b/samples/baremetal_riscv64/runner.c
new file mode 100644
index 0000000..b69c1be
--- /dev/null
+++ b/samples/baremetal_riscv64/runner.c
@@ -0,0 +1,188 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+// Bare-metal single-op runner using the inline HAL: no HAL device, no
+// threads, no filesystem. The backend variant is selected by USE_LLVMCPU below;
+// the matching iree-compile invocations are in CMakeLists.txt.
+//
+// NOTE: Error paths may leak resources. This is acceptable for this single-shot
+// runner because main() reports the error and exits immediately. The successful
+// path releases all resources.
+
+#include <stdio.h>
+
+#include "iree/base/api.h"
+#include "iree/hal/api.h"
+#include "iree/modules/hal/inline/module.h"
+#include "iree/modules/hal/types.h"
+#include "iree/vm/api.h"
+#include "iree/vm/bytecode/module.h"
+
+// USE_LLVMCPU: vmfb compiled with llvm-cpu (inline-dynamic); kernels are real
+// RISC-V machine code in an embedded ELF, loaded via the hal_loader module.
+// Default: vmfb compiled with vmvx-inline (inline-static); kernels are VM
+// bytecode and only the hal_inline module is needed.
+#if defined(USE_LLVMCPU)
+#include "iree/hal/local/loaders/embedded_elf_loader.h"
+#include "iree/modules/hal/loader/module.h"
+#include "samples/baremetal_riscv64/simple_mul_module_llvmcpu_c.h"
+#define simple_mul_module_create \
+  iree_samples_baremetal_riscv64_module_llvmcpu_create
+#else
+#include "samples/baremetal_riscv64/simple_mul_module_vmvx_c.h"
+#define simple_mul_module_create \
+  iree_samples_baremetal_riscv64_module_vmvx_create
+#endif  // USE_LLVMCPU
+
+static iree_status_t make_input_buffer_view(
+    iree_hal_allocator_t* device_allocator, const float* data,
+    iree_host_size_t count, iree_hal_buffer_view_t** out_view) {
+  iree_hal_buffer_params_t params = {
+      .type =
+          IREE_HAL_MEMORY_TYPE_DEVICE_LOCAL | IREE_HAL_MEMORY_TYPE_HOST_VISIBLE,
+      .usage = IREE_HAL_BUFFER_USAGE_DEFAULT,
+  };
+  iree_hal_buffer_t* buffer = NULL;
+  IREE_RETURN_IF_ERROR(iree_hal_allocator_allocate_buffer(
+      device_allocator, params, count * sizeof(float), &buffer));
+  iree_status_t status =
+      iree_hal_buffer_map_write(buffer, 0, data, count * sizeof(float));
+  if (iree_status_is_ok(status)) {
+    iree_hal_dim_t shape[1] = {(iree_hal_dim_t)count};
+    status = iree_hal_buffer_view_create(buffer, 1, shape,
+                                         IREE_HAL_ELEMENT_TYPE_FLOAT_32,
+                                         IREE_HAL_ENCODING_TYPE_DENSE_ROW_MAJOR,
+                                         iree_allocator_system(), out_view);
+  }
+  iree_hal_buffer_release(buffer);
+  return status;
+}
+
+static iree_status_t run(void) {
+  iree_allocator_t host_allocator = iree_allocator_system();
+
+  iree_vm_instance_t* instance = NULL;
+  IREE_RETURN_IF_ERROR(iree_vm_instance_create(IREE_VM_TYPE_CAPACITY_DEFAULT,
+                                               host_allocator, &instance));
+  IREE_RETURN_IF_ERROR(iree_hal_module_register_inline_types(instance));
+
+  iree_hal_allocator_t* device_allocator = NULL;
+  IREE_RETURN_IF_ERROR(iree_hal_allocator_create_heap(
+      iree_make_cstring_view("bare-metal"), host_allocator, host_allocator,
+      &device_allocator));
+
+  iree_vm_module_t* hal_inline_module = NULL;
+  IREE_RETURN_IF_ERROR(iree_hal_inline_module_create(
+      instance, IREE_HAL_INLINE_MODULE_FLAG_NONE,
+      iree_hal_module_debug_sink_stdio(stderr), device_allocator,
+      host_allocator, &hal_inline_module));
+
+#if defined(USE_LLVMCPU)
+  IREE_RETURN_IF_ERROR(iree_hal_module_register_loader_types(instance));
+  iree_hal_executable_loader_t* elf_loader = NULL;
+  IREE_RETURN_IF_ERROR(iree_hal_embedded_elf_loader_create(
+      /*plugin_manager=*/NULL, host_allocator, &elf_loader));
+  iree_vm_module_t* hal_loader_module = NULL;
+  iree_status_t loader_status = iree_hal_loader_module_create(
+      instance, IREE_HAL_LOADER_MODULE_FLAG_NONE, /*loader_count=*/1,
+      &elf_loader, host_allocator, &hal_loader_module);
+  iree_hal_executable_loader_release(elf_loader);
+  IREE_RETURN_IF_ERROR(loader_status);
+#endif  // USE_LLVMCPU
+
+  const struct iree_file_toc_t* module_toc = simple_mul_module_create();
+  iree_vm_module_t* bytecode_module = NULL;
+  IREE_RETURN_IF_ERROR(iree_vm_bytecode_module_create(
+      instance, IREE_VM_BYTECODE_MODULE_FLAG_NONE,
+      iree_make_const_byte_span(module_toc->data, module_toc->size),
+      iree_allocator_null(), host_allocator, &bytecode_module));
+
+  iree_vm_context_t* context = NULL;
+#if defined(USE_LLVMCPU)
+  iree_vm_module_t* modules[] = {hal_inline_module, hal_loader_module,
+                                 bytecode_module};
+#else
+  iree_vm_module_t* modules[] = {hal_inline_module, bytecode_module};
+#endif  // USE_LLVMCPU
+  IREE_RETURN_IF_ERROR(iree_vm_context_create_with_modules(
+      instance, IREE_VM_CONTEXT_FLAG_NONE, IREE_ARRAYSIZE(modules), modules,
+      host_allocator, &context));
+  iree_vm_module_release(hal_inline_module);
+#if defined(USE_LLVMCPU)
+  iree_vm_module_release(hal_loader_module);
+#endif  // USE_LLVMCPU
+  iree_vm_module_release(bytecode_module);
+
+  iree_vm_function_t main_function;
+  IREE_RETURN_IF_ERROR(iree_vm_context_resolve_function(
+      context, iree_make_cstring_view("module.simple_mul"), &main_function));
+
+  const float kFloat4[4] = {4.0f, 4.0f, 4.0f, 4.0f};
+  const float kFloat2[4] = {2.0f, 2.0f, 2.0f, 2.0f};
+  iree_hal_buffer_view_t* arg0 = NULL;
+  iree_hal_buffer_view_t* arg1 = NULL;
+  IREE_RETURN_IF_ERROR(make_input_buffer_view(device_allocator, kFloat4,
+                                              IREE_ARRAYSIZE(kFloat4), &arg0));
+  IREE_RETURN_IF_ERROR(make_input_buffer_view(device_allocator, kFloat2,
+                                              IREE_ARRAYSIZE(kFloat2), &arg1));
+
+  iree_vm_list_t* inputs = NULL;
+  IREE_RETURN_IF_ERROR(iree_vm_list_create(iree_vm_make_undefined_type_def(), 2,
+                                           host_allocator, &inputs));
+  iree_vm_ref_t arg0_ref = iree_hal_buffer_view_move_ref(arg0);
+  iree_vm_ref_t arg1_ref = iree_hal_buffer_view_move_ref(arg1);
+  IREE_RETURN_IF_ERROR(iree_vm_list_push_ref_move(inputs, &arg0_ref));
+  IREE_RETURN_IF_ERROR(iree_vm_list_push_ref_move(inputs, &arg1_ref));
+
+  iree_vm_list_t* outputs = NULL;
+  IREE_RETURN_IF_ERROR(iree_vm_list_create(iree_vm_make_undefined_type_def(), 1,
+                                           host_allocator, &outputs));
+
+  IREE_RETURN_IF_ERROR(
+      iree_vm_invoke(context, main_function, IREE_VM_INVOCATION_FLAG_NONE,
+                     /*policy=*/NULL, inputs, outputs, host_allocator));
+
+  iree_hal_buffer_view_t* ret_view =
+      iree_vm_list_get_buffer_view_assign(outputs, 0);
+  if (!ret_view) {
+    return iree_make_status(IREE_STATUS_NOT_FOUND, "no result buffer view");
+  }
+
+  float results[4] = {0.0f, 0.0f, 0.0f, 0.0f};
+  IREE_RETURN_IF_ERROR(iree_hal_buffer_map_read(
+      iree_hal_buffer_view_buffer(ret_view), 0, results, sizeof(results)));
+
+  printf("simple_mul result: [%f %f %f %f]\n", results[0], results[1],
+         results[2], results[3]);
+  for (iree_host_size_t i = 0; i < IREE_ARRAYSIZE(results); ++i) {
+    if (results[i] != 8.0f) {
+      return iree_make_status(IREE_STATUS_UNKNOWN,
+                              "result mismatch at %zu: %f != 8.0", (size_t)i,
+                              results[i]);
+    }
+  }
+
+  iree_vm_list_release(inputs);
+  iree_vm_list_release(outputs);
+  iree_vm_context_release(context);
+  iree_hal_allocator_release(device_allocator);
+  iree_vm_instance_release(instance);
+  return iree_ok_status();
+}
+
+int main(void) {
+  printf("bare-metal iree runner starting\n");
+  iree_status_t status = run();
+  int ret = (int)iree_status_code(status);
+  if (iree_status_is_ok(status)) {
+    printf("PASS: single-op inference on bare-metal riscv64\n");
+  } else {
+    iree_status_fprint(stderr, status);
+    iree_status_free(status);
+    printf("FAIL\n");
+  }
+  return ret;
+}
diff --git a/samples/baremetal_riscv64/simple_mul.mlir b/samples/baremetal_riscv64/simple_mul.mlir
new file mode 100644
index 0000000..3d8f97e
--- /dev/null
+++ b/samples/baremetal_riscv64/simple_mul.mlir
@@ -0,0 +1,4 @@
+func.func @simple_mul(%arg0: tensor<4xf32>, %arg1: tensor<4xf32>) -> tensor<4xf32> {
+  %0 = arith.mulf %arg0, %arg1 : tensor<4xf32>
+  return %0 : tensor<4xf32>
+}
diff --git a/samples/baremetal_riscv64/start.S b/samples/baremetal_riscv64/start.S
new file mode 100644
index 0000000..374114f
--- /dev/null
+++ b/samples/baremetal_riscv64/start.S
@@ -0,0 +1,17 @@
+// Copyright 2026 The IREE Authors
+//
+// Licensed under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+
+// Minimal boot shim for QEMU's virt machine.
+// QEMU's generic loader sets the PC to the ELF entry point but does not
+// initialize sp. newlib's crt0 (_start) expects sp to be valid. Enable the
+// FPU as well: mstatus.FS starts Off, so FP instructions would otherwise trap.
+    .section .text.enter, "ax", @progbits
+    .globl _enter
+_enter:
+    li t0, 0x6000        // mstatus.FS = 11 (Dirty): enable FPU
+    csrs mstatus, t0
+    la sp, __stack_top
+    tail _start