From: Junjie Cao <[email protected]>
Nothing in tree exercises IOTLB invalidation for any emulated vIOMMU:
the existing iommu-testdev tests only check one-shot translation, so a
regression that failed to flush a stale IOTLB entry would go unnoticed.
Add a test that drives the queued-invalidation path end to end
(vtd_process_inv_desc -> vtd_process_iotlb_desc ->
vtd_iotlb_{global,domain,page}_invalidate). For each
{legacy, scalable-slt, scalable-flt} x {global, domain, page}
combination it:
1. maps IOVA -> PA_A and DMAs, populating the IOTLB;
2. rewrites the leaf PTE to PA_B *without* invalidating and DMAs
again, asserting the stale entry is still served (MISMATCH);
3. submits the IOTLB invalidation plus a wait descriptor, then DMAs
and asserts the fresh page walk now reaches PA_B.
Step 2 makes the flush observable: it fails loudly if the IOTLB
is not actually caching the first translation.
For scalable first-level (flt), QEMU keeps first- and second-level
mappings in a single IOTLB that the legacy VTD_INV_DESC_IOTLB descriptor
flushes for every level, so the test uses that descriptor across all
modes. PASID-selective invalidation (VTD_INV_DESC_PIOTLB, vtd_piotlb_*)
is a separate path and is left for a follow-up.
It also adds three page-selective cases that cache a second page and check
its fate after invalidating the first: for second-level (legacy, scalable-slt)
the second page survives, while for first-level (scalable-flt) QEMU flushes all
first-stage entries of the domain, so it does not. This distinguishes a
page-selective flush from a domain-wide or global one.
Signed-off-by: Junjie Cao <[email protected]>
Reviewed-by: Michael S. Tsirkin <[email protected]>
Signed-off-by: Michael S. Tsirkin <[email protected]>
Message-ID: <[email protected]>
---
MAINTAINERS | 1 +
tests/qtest/iommu-intel-inv-test.c | 346 +++++++++++++++++++++++++++++
tests/qtest/meson.build | 2 +-
3 files changed, 348 insertions(+), 1 deletion(-)
create mode 100644 tests/qtest/iommu-intel-inv-test.c
diff --git a/MAINTAINERS b/MAINTAINERS
index 5ee7c1f601..cbdfce4866 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -4106,6 +4106,7 @@ F: include/hw/i386/intel_iommu.h
F: tests/functional/x86_64/test_intel_iommu.py
F: tests/qtest/intel-iommu-test.c
F: tests/qtest/iommu-intel-test.c
+F: tests/qtest/iommu-intel-inv-test.c
AMD-Vi Emulation
M: Alejandro Jimenez <[email protected]>
diff --git a/tests/qtest/iommu-intel-inv-test.c
b/tests/qtest/iommu-intel-inv-test.c
new file mode 100644
index 0000000000..1467eef647
--- /dev/null
+++ b/tests/qtest/iommu-intel-inv-test.c
@@ -0,0 +1,346 @@
+/*
+ * QTest for Intel IOMMU (VT-d) IOTLB invalidation via Invalidation Queue
+ *
+ * Validates that IOTLB invalidation descriptors submitted through the
+ * queued invalidation interface correctly flush cached translations,
+ * forcing the IOMMU to re-walk page tables on subsequent DMA.
+ *
+ * Copyright (c) 2026 Intel Corporation.
+ *
+ * Author: Junjie Cao <[email protected]>
+ *
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include "qemu/osdep.h"
+#include "libqtest.h"
+#include "libqos/pci.h"
+#include "libqos/pci-pc.h"
+#include "hw/i386/intel_iommu_internal.h"
+#include "hw/misc/iommu-testdev.h"
+#include "libqos/qos-intel-iommu.h"
+#include "libqos/qos-iommu-testdev.h"
+
+#define DMA_LEN 4
+
+/*
+ * Second DMA target page, chosen to fall well outside any address used by
+ * qos-intel-iommu's fixed structure layout.
+ */
+#define QVTD_PT_VAL_B (QVTD_MEM_BASE + 0x00200000)
+
+/*
+ * A second IOVA/target page for the page-selectivity test. QVTD_IOVA_2 is
+ * QVTD_IOVA + 4K: it shares the L4/L3/L2 walk built by
+ * qvtd_build_translation() and differs only in the leaf (L1) slot, so mapping
+ * it costs one extra leaf PTE. QVTD_PT_VAL_2 is its distinct target page.
+ */
+#define QVTD_IOVA_2 (QVTD_IOVA + 0x1000)
+#define QVTD_PT_VAL_2 (QVTD_MEM_BASE + 0x00300000)
+
+typedef enum {
+ IOTLB_INV_GLOBAL,
+ IOTLB_INV_DOMAIN,
+ IOTLB_INV_PAGE,
+} IOTLBInvGranularity;
+
+/*
+ * Core invalidation test, parameterized by translation mode and
+ * invalidation granularity.
+ *
+ * The iommu-testdev device performs DMA writes via the IOMMU (using the
+ * IOVA) and verifies by reading back from the expected GPA directly. If
+ * the IOTLB is stale, the DMA write lands at the old PA while readback
+ * uses the GPA we supply, causing a mismatch (ITD_DMA_ERR_MISMATCH).
+ *
+ * Test sequence:
+ * 1. Setup translation: IOVA -> PA_A
+ * 2. DMA(gpa=PA_A) -> success (IOTLB populates cache)
+ * 3. Modify PTE: IOVA -> PA_B (no invalidation)
+ * 4. DMA(gpa=PA_B) -> MISMATCH (stale IOTLB directs write to PA_A)
+ * 5. Issue IOTLB invalidation + wait
+ * 6. DMA(gpa=PA_B) -> success (cache flushed, fresh page walk)
+ *
+ * Phase 4 depends on QEMU's IOTLB caching the Phase 1 translation; if a
+ * future change makes IOTLB caching lazy this assertion would no longer
+ * exercise the stale-cache path.
+ */
+static void run_iotlb_inv_test(QVTDTransMode mode, IOTLBInvGranularity gran)
+{
+ QTestState *qts;
+ QPCIBus *pcibus;
+ QPCIDevice *dev;
+ QPCIBar bar;
+ uint32_t tail = 0;
+ uint32_t result;
+ uint64_t pa_a, pa_b;
+
+ if (!qtest_has_machine("q35")) {
+ g_test_skip("q35 machine not available");
+ return;
+ }
+
+ qts = qtest_initf("-machine q35 -smp 1 -m 512 -net none "
+ "%s -device iommu-testdev",
+ qvtd_iommu_args(mode));
+
+ if (!qvtd_check_caps(qts, mode)) {
+ qtest_quit(qts);
+ return;
+ }
+
+ dev = qvtd_setup_qtest_pci_device(qts, &pcibus, &bar);
+
+ /*
+ * The IOMMU translates an IOVA to a page base, then the page offset
+ * from the IOVA is added. So GPA = page_base + (IOVA & 0xfff).
+ */
+ pa_a = (QVTD_PT_VAL & VTD_PAGE_MASK_4K) + (QVTD_IOVA & 0xfff);
+ pa_b = (QVTD_PT_VAL_B & VTD_PAGE_MASK_4K) + (QVTD_IOVA & 0xfff);
+
+ /* --- Phase 1: Setup and initial DMA (populates IOTLB) --- */
+ qvtd_build_translation(qts, mode, dev->devfn);
+ qvtd_program_regs(qts, Q35_HOST_BRIDGE_IOMMU_ADDR, mode);
+
+ qtest_memset(qts, pa_a, 0, DMA_LEN);
+ qtest_memset(qts, pa_b, 0, DMA_LEN);
+
+ result = qos_iommu_testdev_trigger_dma(dev, bar, QVTD_IOVA, pa_a,
+ DMA_LEN, 0);
+ g_assert_cmpuint(result, ==, 0);
+
+ /* --- Phase 2: Modify PTE without invalidation -> stale IOTLB --- */
+ qtest_writeq(qts, qvtd_leaf_pte_addr(QVTD_IOVA),
+ qvtd_make_leaf_pte(QVTD_PT_VAL_B & VTD_PAGE_MASK_4K, mode));
+ qtest_memset(qts, pa_a, 0, DMA_LEN);
+ qtest_memset(qts, pa_b, 0, DMA_LEN);
+
+ result = qos_iommu_testdev_trigger_dma(dev, bar, QVTD_IOVA, pa_b,
+ DMA_LEN, 0);
+ g_assert_cmpuint(result, ==, ITD_DMA_ERR_MISMATCH);
+
+ /* --- Phase 3: Invalidate IOTLB -> fresh page walk succeeds --- */
+ switch (gran) {
+ case IOTLB_INV_GLOBAL:
+ tail = qvtd_submit_iotlb_global_inv(qts, Q35_HOST_BRIDGE_IOMMU_ADDR,
+ tail);
+ break;
+ case IOTLB_INV_DOMAIN:
+ tail = qvtd_submit_iotlb_domain_inv(qts, Q35_HOST_BRIDGE_IOMMU_ADDR,
+ QVTD_DOMAIN_ID, tail);
+ break;
+ case IOTLB_INV_PAGE:
+ tail = qvtd_submit_iotlb_page_inv(qts, Q35_HOST_BRIDGE_IOMMU_ADDR,
+ QVTD_DOMAIN_ID, QVTD_IOVA, 0,
+ tail);
+ break;
+ }
+ tail = qvtd_submit_inv_wait_and_poll(qts, Q35_HOST_BRIDGE_IOMMU_ADDR,
+ tail);
+
+ qtest_memset(qts, pa_a, 0, DMA_LEN);
+ qtest_memset(qts, pa_b, 0, DMA_LEN);
+
+ result = qos_iommu_testdev_trigger_dma(dev, bar, QVTD_IOVA, pa_b,
+ DMA_LEN, 0);
+ g_assert_cmpuint(result, ==, 0);
+
+ g_free(dev);
+ qpci_free_pc(pcibus);
+ qtest_quit(qts);
+}
+
+/*
+ * Page-selectivity test: verify that a page-selective invalidation flushes
+ * the named page and touches other cached pages only as far as the model
+ * intends. run_iotlb_inv_test() caches a single entry, so it cannot tell a
+ * page-selective flush from a domain-wide or global one; this test caches two
+ * pages in the same domain and checks the second one's fate.
+ *
+ * The expected fate of the second page depends on the translation level:
+ *
+ * - second-level (legacy / scalable-slt): a page-selective descriptor
+ * evicts only the matching gfn, so IOVA_2 survives.
+ * - first-level (scalable-flt): QEMU invalidates all first-stage entries of
+ * the domain on a page-selective descriptor (vtd_hash_remove_by_page()
+ * returns true for any pgtt==FST entry of the domain, matching the VT-d
+ * spec for first-stage IOTLB invalidation), so IOVA_2 is flushed too.
+ *
+ * Method: map IOVA -> PA_A and IOVA_2 -> PA_A_2, DMA both to populate two
+ * IOTLB entries, rewrite both leaf PTEs to PA_B* without invalidating, then
+ * page-invalidate IOVA only. IOVA always re-walks to PA_B. For IOVA_2 we
+ * verify the DMA against its *original* page PA_A_2: if the entry survived,
+ * the stale cache still serves PA_A_2 (success); if it was flushed, the fresh
+ * walk reaches PA_B_2 and mismatches PA_A_2. So a survived entry gives
+ * success and a flushed entry gives MISMATCH, and we assert whichever the
+ * mode requires -- catching both an over-matching second-level flush and a
+ * regression that stopped flushing first-stage entries domain-wide.
+ */
+static void run_page_selectivity_test(QVTDTransMode mode)
+{
+ QTestState *qts;
+ QPCIBus *pcibus;
+ QPCIDevice *dev;
+ QPCIBar bar;
+ uint32_t tail = 0;
+ uint32_t result;
+ uint64_t pa_a, pa_b, pa_a2, pa_b2;
+ bool fl_domain_wide = (mode == QVTD_TM_SCALABLE_FLT);
+
+ if (!qtest_has_machine("q35")) {
+ g_test_skip("q35 machine not available");
+ return;
+ }
+
+ qts = qtest_initf("-machine q35 -smp 1 -m 512 -net none "
+ "%s -device iommu-testdev",
+ qvtd_iommu_args(mode));
+
+ if (!qvtd_check_caps(qts, mode)) {
+ qtest_quit(qts);
+ return;
+ }
+
+ dev = qvtd_setup_qtest_pci_device(qts, &pcibus, &bar);
+
+ pa_a = (QVTD_PT_VAL & VTD_PAGE_MASK_4K) + (QVTD_IOVA & 0xfff);
+ pa_b = (QVTD_PT_VAL_B & VTD_PAGE_MASK_4K) + (QVTD_IOVA & 0xfff);
+ pa_a2 = (QVTD_PT_VAL_2 & VTD_PAGE_MASK_4K) + (QVTD_IOVA_2 & 0xfff);
+ pa_b2 = (QVTD_PT_VAL_B & VTD_PAGE_MASK_4K) + (QVTD_IOVA_2 & 0xfff);
+
+ /* --- Setup: IOVA -> PA_A (built by helper) and IOVA_2 -> PA_A_2 --- */
+ qvtd_build_translation(qts, mode, dev->devfn);
+ qtest_writeq(qts, qvtd_leaf_pte_addr(QVTD_IOVA_2),
+ qvtd_make_leaf_pte(QVTD_PT_VAL_2 & VTD_PAGE_MASK_4K, mode));
+ qvtd_program_regs(qts, Q35_HOST_BRIDGE_IOMMU_ADDR, mode);
+
+ /* Populate both IOTLB entries. */
+ qtest_memset(qts, pa_a, 0, DMA_LEN);
+ qtest_memset(qts, pa_a2, 0, DMA_LEN);
+ result = qos_iommu_testdev_trigger_dma(dev, bar, QVTD_IOVA, pa_a,
+ DMA_LEN, 0);
+ g_assert_cmpuint(result, ==, 0);
+ result = qos_iommu_testdev_trigger_dma(dev, bar, QVTD_IOVA_2, pa_a2,
+ DMA_LEN, 0);
+ g_assert_cmpuint(result, ==, 0);
+
+ /* Rewrite both leaf PTEs to PA_B* without invalidating. */
+ qtest_writeq(qts, qvtd_leaf_pte_addr(QVTD_IOVA),
+ qvtd_make_leaf_pte(QVTD_PT_VAL_B & VTD_PAGE_MASK_4K, mode));
+ qtest_writeq(qts, qvtd_leaf_pte_addr(QVTD_IOVA_2),
+ qvtd_make_leaf_pte(QVTD_PT_VAL_B & VTD_PAGE_MASK_4K, mode));
+
+ /* Page-selective invalidation of IOVA only. */
+ tail = qvtd_submit_iotlb_page_inv(qts, Q35_HOST_BRIDGE_IOMMU_ADDR,
+ QVTD_DOMAIN_ID, QVTD_IOVA, 0, tail);
+ tail = qvtd_submit_inv_wait_and_poll(qts, Q35_HOST_BRIDGE_IOMMU_ADDR,
+ tail);
+
+ /* IOVA was flushed: fresh walk reaches PA_B. */
+ qtest_memset(qts, pa_a, 0, DMA_LEN);
+ qtest_memset(qts, pa_b, 0, DMA_LEN);
+ result = qos_iommu_testdev_trigger_dma(dev, bar, QVTD_IOVA, pa_b,
+ DMA_LEN, 0);
+ g_assert_cmpuint(result, ==, 0);
+
+ /*
+ * IOVA_2's fate, verified against its original page PA_A_2:
+ * - second-level: entry survives, stale cache serves PA_A_2 -> success;
+ * - first-level: entry was flushed domain-wide, fresh walk reaches
+ * PA_B_2 -> MISMATCH against PA_A_2.
+ */
+ qtest_memset(qts, pa_a2, 0, DMA_LEN);
+ qtest_memset(qts, pa_b2, 0, DMA_LEN);
+ result = qos_iommu_testdev_trigger_dma(dev, bar, QVTD_IOVA_2, pa_a2,
+ DMA_LEN, 0);
+ if (fl_domain_wide) {
+ g_assert_cmpuint(result, ==, ITD_DMA_ERR_MISMATCH);
+ } else {
+ g_assert_cmpuint(result, ==, 0);
+ }
+
+ g_free(dev);
+ qpci_free_pc(pcibus);
+ qtest_quit(qts);
+}
+
+/*
+ * scalable-flt is covered here even though, per the VT-d spec, first-level
+ * mappings are invalidated with the PASID-based descriptor
+ * (VTD_INV_DESC_PIOTLB). QEMU keeps first- and second-level mappings in a
+ * single IOTLB that the legacy VTD_INV_DESC_IOTLB descriptor flushes for
+ * every level, so this test drives that descriptor across all modes.
+ * PASID-selective (PIOTLB) invalidation is a separate path, left for a
+ * follow-up.
+ */
+static const struct {
+ const char *name;
+ QVTDTransMode mode;
+} trans_modes[] = {
+ { "legacy", QVTD_TM_LEGACY_TRANS },
+ { "scalable-slt", QVTD_TM_SCALABLE_SLT },
+ { "scalable-flt", QVTD_TM_SCALABLE_FLT },
+};
+
+static const struct {
+ const char *name;
+ IOTLBInvGranularity gran;
+} granularities[] = {
+ { "global", IOTLB_INV_GLOBAL },
+ { "domain", IOTLB_INV_DOMAIN },
+ { "page", IOTLB_INV_PAGE },
+};
+
+typedef struct {
+ QVTDTransMode mode;
+ IOTLBInvGranularity gran;
+} TestCase;
+
+static void test_iotlb_inv(const void *opaque)
+{
+ const TestCase *tc = opaque;
+
+ run_iotlb_inv_test(tc->mode, tc->gran);
+}
+
+static void test_page_selectivity(const void *opaque)
+{
+ const QVTDTransMode *mode = opaque;
+
+ run_page_selectivity_test(*mode);
+}
+
+int main(int argc, char **argv)
+{
+ g_test_init(&argc, &argv, NULL);
+
+ for (size_t m = 0; m < ARRAY_SIZE(trans_modes); m++) {
+ for (size_t g = 0; g < ARRAY_SIZE(granularities); g++) {
+ TestCase *tc = g_new(TestCase, 1);
+ char *path;
+
+ tc->mode = trans_modes[m].mode;
+ tc->gran = granularities[g].gran;
+
+ path = g_strdup_printf("/iommu-testdev/intel/iotlb-inv/%s-%s",
+ granularities[g].name,
+ trans_modes[m].name);
+ qtest_add_data_func_full(path, tc, test_iotlb_inv, g_free);
+ g_free(path);
+ }
+ }
+
+ for (size_t m = 0; m < ARRAY_SIZE(trans_modes); m++) {
+ QVTDTransMode *mode = g_new(QVTDTransMode, 1);
+ char *path;
+
+ *mode = trans_modes[m].mode;
+ path = g_strdup_printf(
+ "/iommu-testdev/intel/iotlb-inv/page-selective/%s",
+ trans_modes[m].name);
+ qtest_add_data_func_full(path, mode, test_page_selectivity, g_free);
+ g_free(path);
+ }
+
+ return g_test_run();
+}
diff --git a/tests/qtest/meson.build b/tests/qtest/meson.build
index 93175baa87..56ff860e21 100644
--- a/tests/qtest/meson.build
+++ b/tests/qtest/meson.build
@@ -100,7 +100,7 @@ qtests_i386 = \
(config_all_devices.has_key('CONFIG_AMD_IOMMU') ? ['amd-iommu-test'] : []) +
\
(config_all_devices.has_key('CONFIG_VTD') ? ['intel-iommu-test'] : []) +
\
(config_all_devices.has_key('CONFIG_VTD') and
- config_all_devices.has_key('CONFIG_IOMMU_TESTDEV') ? ['iommu-intel-test'] :
[]) + \
+ config_all_devices.has_key('CONFIG_IOMMU_TESTDEV') ? ['iommu-intel-test',
'iommu-intel-inv-test'] : []) + \
(host_os != 'windows' and
\
config_all_devices.has_key('CONFIG_ACPI_ERST') ? ['erst-test'] : []) +
\
(config_all_devices.has_key('CONFIG_PCIE_PORT') and
\
--
MST