Skip to content

openvmm: zero-extend wide reads of the RTC's ports - #1563

Merged
Pedro Henrique Penna (ppenna) merged 2 commits into
devfrom
agents/issue-1151-rtc-wide-reads
Nov 13, 2026
Merged

Pedro Henrique Penna (ppenna) merged 2 commits into
devfrom
agents/issue-1151-rtc-wide-reads

Conversation

@ppenna

@ppenna Pedro Henrique Penna (ppenna) commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1151

What and why

A guest's 16- or 32-bit in from the CMOS RTC's index port 0x70 or data port 0x71 returned the register's byte with the guest's own upper bytes of AX or EAX left in place, as an 8-bit in does, on MSHV and WHP. The RTC's io_read says that a read wider than a byte returns the register's byte zero-extended to the size of the access.

Root cause, in OpenVMM: Rtc::io_read stored the register's byte in data[0] and left the rest of the buffer as the caller passed it, and what that buffer holds depends on the backend:

  • On MSHV and WHP, virt_support_x86emu::emulate::emulate_io passes the guest's EAX as the buffer, so the guest's upper bytes survived the read.
  • On KVM, OpenVMM passes the kvm_run PIO data area, as the issue says, but KVM zeroes it before it exits to the VMM for an in (emulator_pio_in_out in arch/x86/kvm/x86.c, in Linux 5.15, 6.1, 6.8, and master), so the reads were zero-extended there by accident. The issue's explanation of stale bytes from an earlier exit does not hold on these kernels.
  • The string ins path through x86emu passes a zeroed buffer on every backend.

The stale bytes are the guest's own register bytes, or zeros, so no host data reaches the guest.

The pin, microsoft/openvmm#5072 at 60e160369b1f1f10543e0699d4ae1e95810a1e62, adds one commit to feature/microvm/dev, 6f57a6d61, #1066's pin:

  • 60e160369 chipset: zero-extend wide reads of the RTC's ports. io_read reads the register first, so that a read of another port still fails with InvalidRegister and leaves the buffer as it was, then zeroes the whole buffer and stores the register's byte in its first byte, as the DMA controller's io_read does. A one-byte read returns the same byte as before, and a read of port 0x71 still reads the selected register once. Its test, wide_reads_are_zero_extended, reads both ports with 2- and 4-byte buffers that hold 0xa5 bytes.

The NVX commits:

  • 403184f58c4 openvmm: pin the RTC's zero-extended wide reads. It changes only the gitlink, from 6f57a6d61 to 60e160369.
  • 0e159a5808e doc: map the RTC's zero-extended wide reads in the OpenVMM roadmap. Position 267, unassigned.

Decisions:

  • The device fills the whole buffer, rather than vmotherboard's dispatch zeroing it for every device: the chipset devices already own the whole buffer, as the PIC, the PIT, and the i8042 reject a wide access with InvalidAccessSize, and the DMA controller and the microVM devices fill the buffer.
  • One test, not the two of the issue's patch: its second test checks one-byte reads, which test_default, test_readonly, and test_writeable already cover through get_cmos_data.
  • No saved-state field, snapshot format, contract, or version changes.

Not in this change, found while auditing the read path:

  1. The PIIX4 RTC's extended ports. Piix4CmosRtc::io_read in chipset_legacy, which the microVM does not use, also writes only data[0] for ports 0x72, 0x73, 0x76, and 0x77. Its other ports go to Rtc::io_read, which this change fixes.
  2. The upper half of RAX after a narrow in on MSHV and WHP. emulate_io returns RAX with bits 63:32 cleared after an 8- or 16-bit in, where the processor keeps bits 63:8 or 63:16, as KVM's kvm_fast_pio_in does. This is read from the code, not reproduced.

Validation

Local checks at this head, 0e159a5808e (Windows):

  • python scripts/openvmm_pin.py check-branch --topic: passed (2 signed commits, no coordinated files).
  • python scripts/openvmm_pin.py check-roadmap --worktree: passed (267 positions, 276 references).
  • python scripts/openvmm_pin.py source 60e160369b1f1f10543e0699d4ae1e95810a1e62 --pr 5072: good signature, and the revision is the pull request's head.
  • python scripts/nvx.py verify: passed.
  • python scripts/local_gates.py --base origin/dev --only test-changes markdown-links test-layout openvmm-vectors openvmm-cpu-profiles --run: passed. That is check_test_changes.py (0 changes to existing tests), check_markdown_links.py, check_test_layout.py, and the ignored openvmm_vectors (4) and openvmm_cpu_profiles (1) tests of nvx-rs.
  • The nvx-cli gate, python -m unittest discover over scripts/: not run locally. This change does not touch scripts/, and the gate takes over two hours on this shared machine. It is left to CI's NVX CLI / Linux and NVX CLI / Windows jobs.
  • The openvmm-ci gate, python scripts/nvx.py check-openvmm-ci --revision 60e160369b1f1f10543e0699d4ae1e95810a1e62: unit tests [x64-linux] passed, and the other required jobs are running in run 43352068114, which this pull request's openvmm-ci job waits for.

OpenVMM package checks for chipset, at 60e160369: on Windows, cargo check, cargo clippy --all-targets (clean apart from the environment's LNK4199 linker notices), cargo doc --no-deps, and cargo nextest run --profile agent (113 passed), then cargo xtask fmt --fix (no changes); on Linux, on nvx-kvm-test, cargo nextest run --profile agent and cargo test --lib (113 passed each). At 6f57a6d61, the new test fails: "port 0x70, 2 bytes", [64, 165] where [64, 0] is expected. On upstream main with the commit replayed, which applies without conflicts, chipset type-checks and its 64 tests pass.

Reproduction, in NVX's Alpine microVM: a static guest program that selects status D, 0x80, and reads each port with an 8-, a 16-, and a 32-bit in, each starting with EAX = 0xdeadbeef, three times per boot, with identical results each time. A 16-bit in keeps EAX[31:16], so 0xdead00NN is its zero-extended result:

Profile Backend Host type OpenVMM inw 0x70 inl 0x70 inw 0x71 inl 0x71
Expected dead000d 0000000d dead0080 00000080
nvx-kvm-test kvm virtual-machine before: 6f57a6d61 dead000d 0000000d dead0080 00000080
nvx-mshv-test mshv virtual-machine before: 6f57a6d61 deadbe0d deadbe0d deadbe80 deadbe80
nvx-whp-test whp virtual-machine before: 6f57a6d61 deadbe0d deadbe0d deadbe80 deadbe80
nvx-kvm-test kvm virtual-machine after: 60e160369 dead000d 0000000d dead0080 00000080
nvx-mshv-test mshv virtual-machine after: 60e160369 dead000d 0000000d dead0080 00000080
nvx-whp-test whp virtual-machine after: 60e160369 dead000d 0000000d dead0080 00000080

The 8-bit reads returned deadbe0d and deadbe80 in every run. The "before" runs used a temporary worktree of 8e9e9eb74cf on each host, with the artifacts and OpenVMM of the release v0.1.0-dev.8e9e9eb74cf2, which nvx-rs download installed and whose OpenVMM provenance is 6f57a6d61 (clean). The "after" runs used a temporary worktree of 6df1012c3f9, whose tree, 0a0e4918e, is this head's, with the same release's guest artifacts and OpenVMM built from 60e160369 (clean) by nvx.py build-openvmm --backend BACKEND, which replaced the release's binary: 3d3e0af6 on KVM, 7564afc5 on MSHV, and d753a8e6 on WHP. The probe is a 1296-byte static executable, SHA-256 b14268f0eea0b5ba16a277968a929bc0571c458b8119d6248def90ef0b25aec9, built with GCC 13.2.0 on nvx-kvm-test and typed into the guest through run_guest_script:

The probe's source, built with gcc -static -nostdlib -ffreestanding -fno-pie -no-pie -Os -mgeneral-regs-only -fno-stack-protector -fno-asynchronous-unwind-tables -fcf-protection=none -Wall -Wextra -Werror -Wl,--build-id=none -Wl,-z,noseparate-code -Wl,-z,noexecstack -s
/*
 * Probe of microsoft/nvx#1151: reads the RTC's index port 0x70 and data port
 * 0x71 with 8-, 16-, and 32-bit IN instructions, each starting with
 * EAX = 0xdeadbeef after selecting status D, and prints EAX after each one.
 *
 * OpenVMM's RTC zero-extends the register's byte to the width of the access,
 * so an 8-bit read must give 0xdeadbeNN, a 16-bit read 0xdead00NN (the
 * instruction keeps EAX[31:16]), and a 32-bit read 0x000000NN.
 */
#define SYS_write 1
#define SYS_exit 60
#define SYS_ioperm 173

static long sys3(long number, long a, long b, long c)
{
    long ret;

    __asm__ volatile("syscall"
                     : "=a"(ret)
                     : "a"(number), "D"(a), "S"(b), "d"(c)
                     : "rcx", "r11", "memory");
    return ret;
}

static void out8(unsigned short port, unsigned char value)
{
    __asm__ volatile("outb %b0, %w1" : : "a"(value), "Nd"(port));
}

static unsigned int in8(unsigned short port)
{
    unsigned int eax = 0xdeadbeef;

    __asm__ volatile("inb %w1, %b0" : "+a"(eax) : "Nd"(port));
    return eax;
}

static unsigned int in16(unsigned short port)
{
    unsigned int eax = 0xdeadbeef;

    __asm__ volatile("inw %w1, %w0" : "+a"(eax) : "Nd"(port));
    return eax;
}

static unsigned int in32(unsigned short port)
{
    unsigned int eax = 0xdeadbeef;

    __asm__ volatile("inl %w1, %0" : "+a"(eax) : "Nd"(port));
    return eax;
}

static char line[] = "NVX-RTCWIDE inX 0x7X eax=XXXXXXXX\n";

static void report(char width, unsigned short port, unsigned int eax)
{
    static const char digits[] = "0123456789abcdef";
    int i;

    line[14] = width;
    line[19] = digits[port & 15];
    for (i = 0; i < 8; i++)
        line[25 + i] = digits[(eax >> (28 - 4 * i)) & 15];
    sys3(SYS_write, 1, (long)line, sizeof(line) - 1);
}

void _start(void)
{
    unsigned short port;

    /*
     * The processor checks the permission of every port that an access
     * covers: a 32-bit read of port 0x71 covers ports 0x71-0x74.
     */
    if (sys3(SYS_ioperm, 0x70, 5, 1) != 0) {
        sys3(SYS_write, 2, (long)"NVX-RTCWIDE ioperm failed\n", 26);
        sys3(SYS_exit, 2, 0, 0);
    }
    for (port = 0x70; port <= 0x71; port++) {
        out8(0x70, 0x0d);
        report('b', port, in8(port));
        out8(0x70, 0x0d);
        report('w', port, in16(port));
        out8(0x70, 0x0d);
        report('l', port, in32(port));
    }
    sys3(SYS_exit, 0, 0, 0);
    for (;;)
        ;
}

Remote runs at the head's tree, in the temporary worktree of 6df1012c3f9, with OpenVMM built from 60e160369. The scenarios are guest-boot, lifecycle, which reads the RTC's status B one byte at a time through /dev/port, interrupt-map, snapshot-core, restore-processors, smp-snapshot, restore-downtime, time-abi-conformance, and time-host-correction, with --processors 1 2:

Profile Backend Host type Check Result
nvx-kvm-test kvm virtual-machine cargo nextest run --profile agent -p chipset and cargo test -p chipset --lib in openvmm/ 113 passed each
nvx-kvm-test kvm virtual-machine the scenarios, OpenVMM binary 3d3e0af6 passed, in 239 s
nvx-mshv-test mshv virtual-machine the scenarios, OpenVMM binary 7564afc5 (musl) passed, in 233 s
nvx-whp-test whp virtual-machine the scenarios, OpenVMM binary d753a8e6 passed, in 245 s

NVX's guests read the RTC one byte at a time, so the scenarios check that guests still boot, read the RTC, capture, and restore through the changed device on each backend; the probe is the reproduction. Bare-metal hosts and the remaining scenarios are left to CI.

CI at this head, 0e159a5808e: passed, run 43354885451, with OpenVMM CI of the pinned revision, which found that OpenVMM's CI of 60e160369, run 43352068114, passed every job that NVX requires. That run's run vmm-tests [x64-windows-amd-snp], which NVX does not require, failed in one of its 114 tests, multiarch::vmgs::hyperv_openhcl_uefi_x64_windows_datacenter_core_2025_x64_prepped_snp_vmgstool_copy_igvmfile, whose Hyper-V VM never started: the runner's Hyper-V failed to load the CVM firmware vmfirmwarecvm.dll (0x80070002). The same test failed the same way at 6f57a6d61, in microsoft/openvmm#5067's run 43331611028, so it predates this change.

No existing test changes, so no tests-modified label.

OpenVMM's emulated CMOS RTC stored only the first byte of a read of its
ports, 0x70 and 0x71, although its comment says that a wider read
returns the register's byte zero-extended to the size of the access.
The other bytes kept what the backend's buffer held. On MSHV and WHP,
OpenVMM passes the guest's EAX as that buffer, so a 16- or 32-bit IN
from either port kept the guest's upper bytes of AX or EAX, as an 8-bit
IN does (#1151). KVM zeroes its PIO data before it exits to the VMM for
an IN, which hid the defect there.

Pin 60e160369b1f1f10543e0699d4ae1e95810a1e62, the head of
microsoft/openvmm#5072, one commit on 6f57a6d61, the head of
feature/microvm/dev:

- 60e160369 chipset: zero-extend wide reads of the RTC's ports

Rtc::io_read now reads the register, then zeroes the whole buffer and
stores the register's byte in its first byte. A one-byte read returns
the same byte as before, and a read of another port still fails
without touching the buffer.

Evidence: in NVX's Alpine microVM, a guest program selected status D,
0x80, and read each port with 16- and 32-bit INs that start with EAX =
0xdeadbeef, three times per boot. With OpenVMM 6f57a6d61, from the
release v0.1.0-dev.8e9e9eb74cf2, every read returned 0xdeadbe0d from
port 0x70 and 0xdeadbe80 from port 0x71 on nvx-mshv-test and
nvx-whp-test. On nvx-kvm-test, the reads returned the zero-extended
values, 0xdead000d and 0x0000000d, and 0xdead0080 and 0x00000080,
since a 16-bit IN keeps EAX[31:16]. With OpenVMM built from the pin,
all three hosts returned the zero-extended values. The revision's new
chipset test fails at 6f57a6d61, where a 2-byte read of port 0x70
returns [0x40, 0xa5], and passes at the pin with the crate's other 112
tests, on Windows and on Linux.

Tests: with OpenVMM built from the pin, the microVM scenarios
guest-boot, lifecycle, which reads the RTC's status B one byte at a
time, interrupt-map, snapshot-core, restore-processors, smp-snapshot,
restore-downtime, time-abi-conformance, and time-host-correction pass
with 1 and 2 processors on nvx-kvm-test, nvx-mshv-test, and
nvx-whp-test.

NVX needs no coordinated change: the control-session vectors, the
source excerpts, the contract versions, the activation bound, and the
CPU profiles that nvx-rs copies from OpenVMM match the revision's.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The NVX pin moves to 60e160369, position 267, 136 commits past the
mapped tip. Position 267, which microsoft/openvmm#5072 proposes for
feature/microvm/dev on its head, 6f57a6d61, is unassigned. The Scope
also says that microsoft/openvmm#5067 merged positions 265 and 266.

Position 267 makes the RTC's io_read zero-extend a read of port 0x70
or 0x71 that is wider than a byte, in code that upstream main has, and
adds a test, in a file that PR10's position 020, PR15's position 057,
and position 200 also change, none of them in the code that it
changes. It changes nothing that a mapped PR adds. Replaying it on
upstream main applies without conflicts, and chipset type-checks there
and its 64 tests pass, so it can go upstream on its own.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the OpenVMM pin and RTC fix documentation update.

No open issues 🎉

What changed in this PR

This PR updates NVX’s OpenVMM pin to fix zero-extension of wide RTC port reads, addressing #1151, and records the fix in the upstream roadmap.

Changes:

  • Updates the documented pin to 60e160369 at position 267.
  • Updates chain counts and adds the RTC fix to the commit mapping.
  • Documents that the fix can be upstreamed independently.
File Description
doc/​openvmm-upstream-roadmap.md Records the new pin, position 267, and the RTC fix’s upstream applicability.

Review effort: Balanced

@ppenna
Pedro Henrique Penna (ppenna) added this pull request to the merge queue Nov 13, 2026
Merged via the queue into dev with commit ccfe9cd Nov 13, 2026
49 checks passed
@ppenna
Pedro Henrique Penna (ppenna) deleted the agents/issue-1151-rtc-wide-reads branch November 13, 2026 04:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

openvmm: wide RTC port reads leave stale bytes in the upper portion of the result

2 participants