[PATCH v3 5/5] KVM: selftests: Verify KVM handling of L1-owned CR0 bits on writes from L2
From: Sean Christopherson
Date: Fri Oct 30 2026 - 17:41:06 EST
From: Mahmoud Abumandour <ma.mandourr@xxxxxxxxx>
Expand the nested exceptions test to verify KVM's handling of VMX's CR0
guest/host masks, i.e. of VMX's conditional interception of CR0 writes.
LMSW in particular is nasty, as it preserves bits 31:4 as well as CR0.PE
if it was already set.
Verify that MOV CR0 and LMSW in L2 that exits to L0, i.e. isn't intercepted
by L1, doesn't modify an L1-owned bits and reads the correct value from the
read shadow. Use CR0.TS to do the bulk of validation, as its value in
hardware can be easily inferred by triggering an #NM in L2. Specifically,
run with CR0.TS=1 in hardware but CR0.TS=0 in the read shadow, and verify
that non-intercepted writes to CR0 from L2 preserve the CR0.TS values in
hardware *and* the shadow.
Note, the downside of using CR0.TS is that any SIMD instructions in L2 will
hit a #NM, which is especially problematic since ucall(), e.g. for guest
prints and asserts, includes a memset(), for which the compiler likes to
generate SSE instructions. To avoid false passes and false failures, have
L2 snapshot the CR0 it sees, but do the actual validation in L1. E.g. if
L2 observed the wrong value, then doing GUEST_ASSERT_EQ() could get a false
pass as memset() would trigger the #NM expected by L1 (when L2 is trying to
report the failure).
Include CR0.NE and CR0.PE in the divergent shadow/hardware values as well
for extra coverage, e.g. CR0.NE is a must-be-1 bit, and CR0.PE is preserved
by LMSW, but if and only if it's already '1'.
Signed-off-by: Mahmoud Abumandour <ma.mandourr@xxxxxxxxx>
Co-developed-by: Sean Christopherson <seanjc@xxxxxxxxxx>
Signed-off-by: Sean Christopherson <seanjc@xxxxxxxxxx>
---
.../selftests/kvm/include/x86/processor.h | 5 ++
.../kvm/x86/nested_exceptions_test.c | 73 +++++++++++++++++++
2 files changed, 78 insertions(+)
diff --git a/tools/testing/selftests/kvm/include/x86/processor.h b/tools/testing/selftests/kvm/include/x86/processor.h
index 5898a277a2e0..79c4b502b00c 100644
--- a/tools/testing/selftests/kvm/include/x86/processor.h
+++ b/tools/testing/selftests/kvm/include/x86/processor.h
@@ -617,6 +617,11 @@ static inline void set_cr0(u64 val)
__asm__ __volatile__("mov %0, %%cr0" : : "r" (val) : "memory");
}
+static inline void lmsw(u8 val)
+{
+ __asm__ __volatile__("lmsw %w0" : : "r" (val) : "memory");
+}
+
static inline u64 get_cr2(void)
{
u64 cr2;
diff --git a/tools/testing/selftests/kvm/x86/nested_exceptions_test.c b/tools/testing/selftests/kvm/x86/nested_exceptions_test.c
index f47432526ea1..e83c1f41703d 100644
--- a/tools/testing/selftests/kvm/x86/nested_exceptions_test.c
+++ b/tools/testing/selftests/kvm/x86/nested_exceptions_test.c
@@ -109,6 +109,51 @@ static void l1_svm_code(struct svm_test_data *svm)
GUEST_DONE();
}
+static u64 l2_cr0;
+
+static void l2_vmx_cr0_test(bool use_lmsw)
+{
+ /*
+ * DANGER!!! DO NOT add anything that might cause the compiler to emit
+ * a "string" operation, e.g. memset(). Compilers often generate SIMD
+ * instructions for memset() and friends, and so will hit an #NM due to
+ * CR0.TS being set in hardware. Note, all ucalls trigger memset()!
+ */
+ u64 cr0 = (get_cr0() ^ X86_CR0_MP);
+
+ if (use_lmsw)
+ lmsw(cr0);
+ else
+ set_cr0(cr0);
+
+ /*
+ * Snapshot CR0 and let L1 check the value; as above, attempting to use
+ * GUEST_ASSERT_EQ() => ucall will likely trigger a premature #NM and
+ * result in a false pass (L1 will see the #NM and think all is well).
+ */
+ l2_cr0 = get_cr0();
+
+ /* FNOP should #NM as CR0.TS is set vmcs12.CR0 and thus vmcs02. */
+ asm volatile("fnop" ::: "memory");
+
+ /*
+ * Signal failure with a #UD, again to avoid ucall/memset(). If FNOP
+ * somehow succeeds but then #NM is triggered by a SIMD instruction in
+ * memset(), the test will get a false pass.
+ */
+ asm volatile("ud2");
+}
+
+static void l2_mov_cr0_test(void)
+{
+ l2_vmx_cr0_test(false);
+}
+
+static void l2_lmsw_test(void)
+{
+ l2_vmx_cr0_test(true);
+}
+
static void vmx_run_l2(void *l2_code, int vector, u32 error_code)
{
vmwrite(GUEST_RIP, (u64)l2_code);
@@ -129,6 +174,8 @@ static void vmx_run_l2(void *l2_code, int vector, u32 error_code)
static void l1_vmx_code(struct vmx_pages *vmx)
{
+ unsigned long cr0;
+
prepare_for_vmx_operation(vmx);
load_vmcs(vmx);
@@ -153,6 +200,32 @@ static void l1_vmx_code(struct vmx_pages *vmx)
vmx_run_l2(l2_ss_injected_tf_test, FAKE_TRIPLE_FAULT_VECTOR, 0);
GUEST_ASSERT_EQ(vmread(VM_EXIT_REASON), EXIT_REASON_TRIPLE_FAULT);
+ /*
+ * Validate KVM's CR0 mask+shadow emulation, for both MOV CR and LMSW.
+ * Set TS and NE in the value loaded in hardware, to verify that doing
+ * e.g. FNOP generates an #NM in L2, but clear the bits in L2's read
+ * shadow and verify that L2 always sees '0' and can't set those bits.
+ */
+ cr0 = vmread(GUEST_CR0) | X86_CR0_TS | X86_CR0_NE;
+ vmwrite(CR0_GUEST_HOST_MASK, X86_CR0_TS | X86_CR0_NE | X86_CR0_PE);
+ vmwrite(CR0_READ_SHADOW, cr0 & ~(X86_CR0_TS | X86_CR0_NE));
+ vmwrite(GUEST_CR0, cr0);
+ vmwrite(EXCEPTION_BITMAP, BIT_ULL(NM_VECTOR));
+
+ vmx_run_l2(l2_mov_cr0_test, NM_VECTOR, 0);
+ GUEST_ASSERT_EQ(l2_cr0, (cr0 & ~(X86_CR0_TS | X86_CR0_NE)) ^ X86_CR0_MP);
+ GUEST_ASSERT_EQ(vmread(GUEST_CR0), cr0 ^ X86_CR0_MP);
+
+ /*
+ * LMSW preserves CR0.PE *if* CR0.PE=1. Clear the bit in the shadow to
+ * validate that KVM preserves CR0.PE in "hardware" even if L2 attempts
+ * to clear CR0.PE via LMSW.
+ */
+ vmwrite(CR0_READ_SHADOW, cr0 & ~(X86_CR0_TS | X86_CR0_NE | X86_CR0_PE));
+ vmx_run_l2(l2_lmsw_test, NM_VECTOR, 0);
+ GUEST_ASSERT_EQ(l2_cr0, cr0 & ~(X86_CR0_TS | X86_CR0_NE | X86_CR0_PE));
+ GUEST_ASSERT_EQ(vmread(GUEST_CR0), cr0);
+
GUEST_DONE();
}
--
2.56.0.385.gac671c819a-goog