From 93e0ffa5b941cc564d2816c0ec2561cf315e96a3 Mon Sep 17 00:00:00 2001 From: cshung <3410332+cshung@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:52:13 -0800 Subject: [PATCH 1/3] feat(aarch64): implement the Linux MSHV backend MSHV provides partition creation, memory mappings, MMIO dispatch, batched register access and replacement-VM reset on ARM64. Native CI uses tagged hierarchical-virtualization runners and bounded scheduling for the partition limit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: cshung <3410332+cshung@users.noreply.github.com> --- .config/nextest.toml | 7 + .github/workflows/ValidatePullRequest.yml | 8 + .github/workflows/dep_build_test.yml | 47 +- CHANGELOG.md | 1 + Justfile | 10 +- README.md | 2 +- .../src/hypervisor/hyperlight_vm/aarch64.rs | 5 +- .../virtual_machine/mshv/aarch64.rs | 477 +++++++++++++++++- src/hyperlight_host/src/mem/memory_region.rs | 12 +- .../src/sandbox/snapshot/file/config.rs | 7 +- src/hyperlight_host/tests/integration_test.rs | 15 +- .../tests/sandbox_host_tests.rs | 23 +- typos.toml | 4 + 13 files changed, 571 insertions(+), 47 deletions(-) create mode 100644 .config/nextest.toml diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 000000000..38ad68b40 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,7 @@ +[profile.arm64-mshv] +test-threads = 4 + +# Internally parallel tests run alone. VM replacement can double their partition count. +[[profile.arm64-mshv.overrides]] +filter = 'test(/thread|parallel|::create_[0-9]+_/) | (binary(integration_test) & test(/^interrupt_/))' +threads-required = "num-test-threads" diff --git a/.github/workflows/ValidatePullRequest.yml b/.github/workflows/ValidatePullRequest.yml index 235fe4779..9ee4cc683 100644 --- a/.github/workflows/ValidatePullRequest.yml +++ b/.github/workflows/ValidatePullRequest.yml @@ -193,6 +193,14 @@ jobs: cpu_vendor: arm arch: arm64 config: release + - hypervisor: mshv3 + cpu_vendor: arm + arch: arm64 + config: debug + - hypervisor: mshv3 + cpu_vendor: arm + arch: arm64 + config: release uses: ./.github/workflows/dep_build_test.yml secrets: inherit with: diff --git a/.github/workflows/dep_build_test.yml b/.github/workflows/dep_build_test.yml index a745a9c78..3df6f79de 100644 --- a/.github/workflows/dep_build_test.yml +++ b/.github/workflows/dep_build_test.yml @@ -33,11 +33,8 @@ on: default: "" full_aarch64: description: >- - When "true", also run the single-driver tests on aarch64. PRs leave - this "false" so the aarch64 runner builds and runs the default Rust - tests; the daily schedule (DailyArm64.yml) sets it "true". This - input only affects aarch64: it does not change X64, where those tests - already run on Linux and are skipped on Windows regardless. + When "true", also run the single-driver tests on Linux aarch64. + ARM64 MSHV always runs its single-driver suite once. required: false type: string default: "false" @@ -62,7 +59,7 @@ defaults: jobs: build-and-test: if: ${{ inputs.docs_only == 'false' }} - timeout-minutes: ${{ fromJSON(vars.BUILD_TEST_JOB_TIMEOUT || '45') }} + timeout-minutes: ${{ fromJSON(vars.BUILD_TEST_JOB_TIMEOUT || (inputs.arch == 'arm64' && inputs.hypervisor == 'mshv3' && '90' || '45')) }} runs-on: ${{ fromJson(inputs.arch == 'X64' && format('["self-hosted", "{0}", "X64", "1ES.Pool=hld-{1}-{2}", "JobId=build-and-test-{3}-{4}-{5}-{6}"]', inputs.hypervisor == 'hyperv-ws2025' && 'Windows' || 'Linux', @@ -78,6 +75,12 @@ jobs: github.run_id, github.run_number, github.run_attempt) + || inputs.hypervisor == 'mshv3' + && format('["self-hosted", "Linux", "ARM64", "1ES.Pool=hld-azlinux3-mshv-arm", "JobId=build-and-test-{0}-{1}-{2}-{3}"]', + inputs.config, + github.run_id, + github.run_number, + github.run_attempt) || format('["self-hosted", "{0}", "arm64", "{1}"]', inputs.hypervisor == 'hvf' && 'macos' || 'Linux', inputs.hypervisor)) }} @@ -158,16 +161,25 @@ jobs: - name: Build run: just build ${{ inputs.config }} - - name: Run Rust tests + - name: Limit ARM64 MSHV test concurrency + if: inputs.arch == 'arm64' && inputs.hypervisor == 'mshv3' run: | - # with default features - just test ${{ inputs.config }} + echo "RUST_TEST_THREADS=1" >> "$GITHUB_ENV" + echo "NEXTEST_PROFILE=arm64-mshv" >> "$GITHUB_ENV" + + - name: Install ARM64 MSHV test runner + if: inputs.arch == 'arm64' && inputs.hypervisor == 'mshv3' + uses: taiki-e/install-action@682ce6c3c696a4cd5168a05f98e51aa02b877941 # v2.89.1 + with: + tool: cargo-nextest@0.9.151 + + - name: Run Rust tests + # ARM64 MSHV selects its driver here to avoid a duplicate full run. + run: just test ${{ inputs.config }} ${{ inputs.arch == 'arm64' && inputs.hypervisor == 'mshv3' && 'mshv3 "nextest run"' || '' }} - name: Run Rust tests with single driver - # Linux-only. Skipped on aarch64 PRs to conserve the limited runners; - # the daily schedule (full_aarch64 == 'true') restores it. On X64 Linux - # it always runs. - if: runner.os == 'Linux' && (inputs.arch == 'X64' || inputs.full_aarch64 == 'true') + # ARM64 MSHV runs this suite in the preceding step. + if: runner.os == 'Linux' && (inputs.arch == 'X64' || (inputs.full_aarch64 == 'true' && inputs.hypervisor != 'mshv3')) run: | # with only one driver enabled (kvm/mshv3 features are unix-only, no-op on Windows) just test ${{ inputs.config }} ${{ inputs.hypervisor == 'mshv3' && 'mshv3' || 'kvm' }} @@ -234,14 +246,15 @@ jobs: if: inputs.arch == 'X64' run: just test-csharp-bindings ${{ inputs.config }} + # Snapshot goldens are not published for ARM64 WHP or MSHV. - name: Install oras - if: runner.os != 'Windows' || inputs.arch != 'arm64' + if: inputs.arch != 'arm64' || (runner.os != 'Windows' && inputs.hypervisor != 'mshv3') uses: oras-project/setup-oras@5e2f2482b30b0cb3e14e67f89a80c88c659a7c63 # v2.0.3 with: version: 1.3.1 - name: Log in to GHCR - if: runner.os != 'Windows' || inputs.arch != 'arm64' + if: inputs.arch != 'arm64' || (runner.os != 'Windows' && inputs.hypervisor != 'mshv3') env: GHCR_USER: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -250,13 +263,13 @@ jobs: echo "${GHCR_TOKEN}" | oras login ghcr.io -u "${GHCR_USER}" --password-stdin - name: Snapshot goldens (pull and verify) - if: ${{ inputs.should_regen_goldens != 'true' && (runner.os != 'Windows' || inputs.arch != 'arm64') }} + if: ${{ inputs.should_regen_goldens != 'true' && (inputs.arch != 'arm64' || (runner.os != 'Windows' && inputs.hypervisor != 'mshv3')) }} run: | just snapshot-goldens-pull ${{ inputs.config }} just snapshot-goldens-verify ${{ inputs.config }} - name: Snapshot goldens (regenerate and verify) - if: ${{ inputs.should_regen_goldens == 'true' && (runner.os != 'Windows' || inputs.arch != 'arm64') }} + if: ${{ inputs.should_regen_goldens == 'true' && (inputs.arch != 'arm64' || (runner.os != 'Windows' && inputs.hypervisor != 'mshv3')) }} run: | # regenerate goldens from current branch just snapshot-goldens-regenerate ${{ inputs.config }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 95fecc54f..1f254b3c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added * Add `Sandbox::recreate()`, which rebuilds a sandbox's VM and memory from a snapshot. It recovers unrecoverable sandboxes and keeps configuration, host functions, and interrupt handles by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/1751 +* ARM64 Linux guests can run on MSHV. ### Fixed * Fix restore on WHP not rewinding the guest TSC on hosts with `TSC_ADJUST` support. diff --git a/Justfile b/Justfile index 528fc4df2..1916792f2 100644 --- a/Justfile +++ b/Justfile @@ -247,7 +247,7 @@ like-ci config=default-target hypervisor="kvm": just check-license-headers # runs all tests -test target=default-target features="": (test-unit target features) (test-isolated target features) (test-integration target features) (test-doc target features) (test-tools target) +test target=default-target features="" runner="test": (test-unit target features runner) (test-isolated target features) (test-integration target features runner) (test-doc target features) (test-tools target) # Cargo launches test binaries through the runner in this variable, for example # CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER. @@ -269,8 +269,8 @@ test-tools target=default-target: {{ cargo-cmd }} test -p hyperlight-ci --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} # runs unit tests -test-unit target=default-target features="": - {{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --lib +test-unit target=default-target features="" runner="test": + {{ cargo-cmd }} {{ runner }} {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} {{ if runner == "test" { "--profile=" } else { "--cargo-profile=" } }}{{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --lib # runs loom concurrency tests test-loom: @@ -296,7 +296,7 @@ test-isolated target=default-target features="" : {{ cargo-cmd }} test {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F function_call_metrics," + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib -- metrics::tests::test_metrics_are_emitted --exact # runs integration tests -test-integration target=default-target features="": +test-integration target=default-target features="" runner="test": @# run execute_on_heap test with feature "executable_heap" on (runs with off during normal tests) {{ cargo-cmd }} test {{ if features =="" {"--features executable_heap"} else if features=="no-default-features" {"--no-default-features --features executable_heap"} else {"--no-default-features -F executable_heap," + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --test integration_test execute_on_heap @@ -308,7 +308,7 @@ test-integration target=default-target features="": @# the glob but no-ops without a mode token, so it needs no golden @# cache here. It verifies in its own step (see the snapshot-goldens @# recipes). - {{ cargo-cmd }} test -p hyperlight-host {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --test '*' + {{ cargo-cmd }} {{ runner }} -p hyperlight-host {{ if features =="" {''} else if features=="no-default-features" {"--no-default-features" } else {"--no-default-features -F " + features } }} {{ if runner == "test" { "--profile=" } else { "--cargo-profile=" } }}{{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --test '*' # tests compilation with no default features on different platforms test-compilation-no-default-features target=default-target: diff --git a/README.md b/README.md index 5f93525aa..dba907c35 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Or skip setup entirely with a codespace: | x86-64 | [KVM] | ✅ Yes | ✅ Yes | ✅ Yes | | x86-64 | [MSHV] | ✅ Yes | ✅ Yes | ✅ Yes | | AArch64 | [KVM] | ✅ Yes | ❌ No | ❌ No | -| AArch64 | [MSHV] | ❌ No | ❌ No | ❌ No | +| AArch64 | [MSHV] | ✅ Yes | ❌ No | ❌ No | ### Windows diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs index 9f527987e..f9a38c4ec 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs @@ -61,7 +61,10 @@ impl HyperlightVm { #[cfg(kvm)] Some(HypervisorType::Kvm) => Box::new(KvmVm::new().map_err(VmError::CreateVm)?), #[cfg(mshv3)] - Some(HypervisorType::Mshv) => return Err(CreateHyperlightVmError::NoHypervisorFound), + Some(HypervisorType::Mshv) => { + use crate::hypervisor::virtual_machine::mshv::MshvVm; + Box::new(MshvVm::new().map_err(VmError::CreateVm)?) + } #[cfg(hvf)] Some(HypervisorType::Hvf) => { Box::new(HvfVm::new(interrupt_handle.clone()).map_err(VmError::CreateVm)?) diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/aarch64.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/aarch64.rs index bb05f73b0..68dd62b41 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/aarch64.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/aarch64.rs @@ -1,27 +1,486 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 The Hyperlight Authors. +use std::sync::LazyLock; + +use hyperlight_common::outb::VmAction; +use mshv_bindings::{ + hv_message_type, hv_message_type_HVMSG_GPA_INTERCEPT, hv_message_type_HVMSG_UNMAPPED_GPA, + hv_partition_property_code_HV_PARTITION_PROPERTY_GIC_LPI_INT_ID_BITS, + hv_partition_property_code_HV_PARTITION_PROPERTY_GIC_PPI_OVERFLOW_INTERRUPT_FROM_CNTV, + hv_partition_property_code_HV_PARTITION_PROPERTY_GIC_PPI_PERFORMANCE_MONITORS_INTERRUPT, + hv_partition_property_code_HV_PARTITION_PROPERTY_GICD_BASE_ADDRESS, + hv_partition_property_code_HV_PARTITION_PROPERTY_GITS_TRANSLATER_BASE_ADDRESS, + hv_register_assoc, hv_register_name_HV_ARM64_REGISTER_CPACR_EL1, + hv_register_name_HV_ARM64_REGISTER_FP, hv_register_name_HV_ARM64_REGISTER_FPCR, + hv_register_name_HV_ARM64_REGISTER_FPSR, hv_register_name_HV_ARM64_REGISTER_GICR_BASE_GPA, + hv_register_name_HV_ARM64_REGISTER_LR, hv_register_name_HV_ARM64_REGISTER_MAIR_EL1, + hv_register_name_HV_ARM64_REGISTER_PC, hv_register_name_HV_ARM64_REGISTER_PSTATE, + hv_register_name_HV_ARM64_REGISTER_Q0, hv_register_name_HV_ARM64_REGISTER_SCTLR_EL1, + hv_register_name_HV_ARM64_REGISTER_SP_EL0, hv_register_name_HV_ARM64_REGISTER_SP_EL1, + hv_register_name_HV_ARM64_REGISTER_TCR_EL1, hv_register_name_HV_ARM64_REGISTER_TTBR0_EL1, + hv_register_name_HV_ARM64_REGISTER_VBAR_EL1, hv_register_name_HV_ARM64_REGISTER_X0, + hv_register_value, hv_u128, mshv_create_partition_v2, mshv_user_mem_region, +}; +use mshv_ioctls::{Mshv, VcpuFd, VmFd}; use tracing::{Span, instrument}; -use crate::hypervisor::virtual_machine::CreateVmError; +use crate::hypervisor::regs::{ + CommonDebugRegs, CommonFpu, CommonRegisters, CommonSpecialRegisters, +}; +use crate::hypervisor::virtual_machine::{ + CreateVmError, MapMemoryError, RegisterError, ResetVcpuError, RunVcpuError, UnmapMemoryError, + VirtualMachine, VmExit, +}; +use crate::mem::memory_region::{MemoryRegion, MemoryRegionFlags}; + +static MSHV: LazyLock> = + LazyLock::new(|| Mshv::new().map_err(|e| CreateVmError::HypervisorNotAvailable(e.into()))); + +fn mmio_write_info(syndrome: u64) -> Option<(u32, usize)> { + const ISV: u64 = 1 << 24; + const WNR: u64 = 1 << 6; + + if syndrome & (ISV | WNR) != ISV | WNR { + return None; + } + + let source_register = ((syndrome >> 16) & 0x1f) as u32; + let access_size = 1usize << ((syndrome >> 22) & 0x3); + Some((source_register, access_size)) +} -/// Return `true` if the MSHV API is available +/// Determine whether the MSHV hypervisor API is available on aarch64. #[instrument(skip_all, parent = Span::current(), level = "Trace")] pub(crate) fn is_hypervisor_present() -> bool { - // TODO(aarch64): implement MSHV detection - false + match Mshv::new() { + Ok(_) => true, + Err(_) => { + tracing::info!("MSHV is not available on this system"); + false + } + } } -/// An MSHV implementation of a single-vcpu VM +/// An MSHV implementation of a single-vcpu VM for aarch64. #[derive(Debug)] pub(crate) struct MshvVm { - _placeholder: (), + vm_fd: VmFd, + vcpu_fd: VcpuFd, + mapped_regions: Vec, } -#[allow(unused)] impl MshvVm { - #[allow(unused)] + /// Create a new MSHV VM instance for aarch64. + #[instrument(skip_all, parent = Span::current(), level = "Trace")] pub(crate) fn new() -> std::result::Result { - unimplemented!("MshvVm::new") + let mshv = MSHV.as_ref().map_err(|e| e.clone())?; + + let pr: mshv_create_partition_v2 = Default::default(); + let vm_fd = mshv + .create_vm_with_args(&pr) + .map_err(|e| CreateVmError::CreateVmFd(e.into()))?; + + for (property, value) in [ + ( + hv_partition_property_code_HV_PARTITION_PROPERTY_GICD_BASE_ADDRESS, + 0xffff0000, + ), + ( + hv_partition_property_code_HV_PARTITION_PROPERTY_GITS_TRANSLATER_BASE_ADDRESS, + 0xeff68000, + ), + ( + hv_partition_property_code_HV_PARTITION_PROPERTY_GIC_LPI_INT_ID_BITS, + 1, + ), + ( + hv_partition_property_code_HV_PARTITION_PROPERTY_GIC_PPI_OVERFLOW_INTERRUPT_FROM_CNTV, + 0x1b, + ), + ( + hv_partition_property_code_HV_PARTITION_PROPERTY_GIC_PPI_PERFORMANCE_MONITORS_INTERRUPT, + 0x17, + ), + ] { + vm_fd + .set_partition_property(property, value) + .map_err(|e| CreateVmError::SetPartitionProperty(e.into()))?; + } + + vm_fd + .initialize() + .map_err(|e| CreateVmError::InitializeVm(e.into()))?; + + let vcpu_fd = vm_fd + .create_vcpu(0) + .map_err(|e| CreateVmError::CreateVcpuFd(e.into()))?; + vcpu_fd + .set_reg(&[hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_GICR_BASE_GPA, + value: hv_register_value { reg64: 0xeffee000 }, + ..Default::default() + }]) + .map_err(|e| CreateVmError::SetPartitionProperty(e.into()))?; + + Ok(Self { + vm_fd, + vcpu_fd, + mapped_regions: Vec::new(), + }) + } + + /// Helper to get a single 64-bit register by name. + fn get_reg64(&self, name: u32) -> Result { + let mut reg = [hv_register_assoc { + name, + ..Default::default() + }]; + self.vcpu_fd + .get_reg(&mut reg) + .map_err(|e| RegisterError::GetRegs(e.into()))?; + Ok(unsafe { reg[0].value.reg64 }) + } +} + +impl VirtualMachine for MshvVm { + unsafe fn map_memory( + &mut self, + (_slot, region): (u32, &MemoryRegion), + ) -> std::result::Result<(), MapMemoryError> { + let mshv_region: mshv_user_mem_region = region.into(); + self.vm_fd + .map_user_memory(mshv_region) + .map_err(|e| MapMemoryError::Hypervisor(e.into()))?; + self.mapped_regions.push(mshv_region); + Ok(()) + } + + fn unmap_memory( + &mut self, + (_slot, region): (u32, &MemoryRegion), + ) -> std::result::Result<(), UnmapMemoryError> { + let mshv_region: mshv_user_mem_region = region.into(); + self.vm_fd + .unmap_user_memory(mshv_region) + .map_err(|e| UnmapMemoryError::Hypervisor(e.into()))?; + self.mapped_regions.retain(|mapped| { + mapped.guest_pfn != mshv_region.guest_pfn + || mapped.size != mshv_region.size + || mapped.userspace_addr != mshv_region.userspace_addr + }); + Ok(()) + } + + fn run_vcpu( + &mut self, + #[cfg(feature = "trace_guest")] _tc: &mut crate::sandbox::trace::TraceContext, + ) -> std::result::Result { + const UNMAPPED_GPA_MESSAGE: hv_message_type = hv_message_type_HVMSG_UNMAPPED_GPA; + const INVALID_GPA_ACCESS_MESSAGE: hv_message_type = hv_message_type_HVMSG_GPA_INTERCEPT; + + match self.vcpu_fd.run() { + Ok(m) => { + let msg_type = m.header.message_type; + match msg_type { + UNMAPPED_GPA_MESSAGE | INVALID_GPA_ACCESS_MESSAGE => { + let mem_msg = m + .to_memory_info() + .map_err(|_| RunVcpuError::DecodeIOMessage(msg_type))?; + let gpa = mem_msg.guest_physical_address; + let io_page_gpa = const { hyperlight_common::layout::io_page().unwrap().0 }; + let is_write = mem_msg.header.intercept_access_type == 1; + + if is_write + && gpa >= io_page_gpa + && (gpa - io_page_gpa) < hyperlight_common::vmem::PAGE_SIZE as u64 + { + let off = (gpa - io_page_gpa) as usize; + let port = off / core::mem::size_of::(); + let pc = mem_msg.header.pc; + let instruction_length = mem_msg.header.instruction_length as u64; + self.vcpu_fd + .set_reg(&[hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_PC, + value: hv_register_value { + reg64: pc.wrapping_add(instruction_length), + }, + ..Default::default() + }]) + .map_err(|e| RunVcpuError::IncrementRip(e.into()))?; + + if port == VmAction::Halt as usize { + return Ok(VmExit::Halt()); + } + + let (source_register, access_size) = mmio_write_info(mem_msg.syndrome) + .ok_or(RunVcpuError::ParseGpaAccessInfo)?; + let value = if source_register == 31 { + 0 + } else { + self.get_reg64( + hv_register_name_HV_ARM64_REGISTER_X0 + source_register, + ) + .map_err(|e| match e { + RegisterError::GetRegs(error) => RunVcpuError::Unknown(error), + _ => unreachable!("get_reg64 returned a non-get error"), + })? + }; + Ok(VmExit::IoOut( + port as u16, + value.to_le_bytes()[..access_size].to_vec(), + )) + } else { + match MemoryRegionFlags::try_from(mem_msg) + .map_err(|_| RunVcpuError::ParseGpaAccessInfo)? + { + MemoryRegionFlags::READ => Ok(VmExit::MmioRead(gpa)), + MemoryRegionFlags::WRITE => Ok(VmExit::MmioWrite(gpa)), + _ => Ok(VmExit::Unknown("Unknown MMIO access".to_string())), + } + } + } + other => Ok(VmExit::Unknown(format!( + "Unknown MSHV VCPU exit: {:?}", + other + ))), + } + } + Err(e) => match e.errno() { + libc::EINTR => Ok(VmExit::Cancelled()), + libc::EAGAIN => Ok(VmExit::Retry()), + _ => Err(RunVcpuError::Unknown(e.into())), + }, + } + } + + fn regs(&self) -> std::result::Result { + let mshv_regs = self + .vcpu_fd + .get_regs() + .map_err(|e| RegisterError::GetRegs(e.into()))?; + Ok(CommonRegisters { + x: mshv_regs.regs, + sp: self.get_reg64(hv_register_name_HV_ARM64_REGISTER_SP_EL0)?, + pc: mshv_regs.pc, + pstate: mshv_regs.pstate, + }) + } + + fn set_regs(&mut self, regs: &CommonRegisters) -> std::result::Result<(), RegisterError> { + let mut mshv_regs = Vec::with_capacity(34); + for (index, value) in regs.x[..29].iter().enumerate() { + mshv_regs.push(hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_X0 + index as u32, + value: hv_register_value { reg64: *value }, + ..Default::default() + }); + } + mshv_regs.extend([ + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_FP, + value: hv_register_value { reg64: regs.x[29] }, + ..Default::default() + }, + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_LR, + value: hv_register_value { reg64: regs.x[30] }, + ..Default::default() + }, + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_SP_EL0, + value: hv_register_value { reg64: regs.sp }, + ..Default::default() + }, + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_PC, + value: hv_register_value { reg64: regs.pc }, + ..Default::default() + }, + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_PSTATE, + value: hv_register_value { reg64: regs.pstate }, + ..Default::default() + }, + ]); + self.vcpu_fd + .set_reg(&mshv_regs) + .map_err(|e| RegisterError::SetRegs(e.into())) + } + + fn fpu(&self) -> std::result::Result { + let mut regs: Vec<_> = (0..32) + .map(|index| hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_Q0 + index, + ..Default::default() + }) + .collect(); + regs.extend([ + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_FPSR, + ..Default::default() + }, + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_FPCR, + ..Default::default() + }, + ]); + self.vcpu_fd + .get_reg(&mut regs) + .map_err(|e| RegisterError::GetFpu(e.into()))?; + + let mut v: [u128; 32] = [0; 32]; + for (value, reg) in v.iter_mut().zip(®s[..32]) { + let reg128 = unsafe { reg.value.reg128 }; + *value = (reg128.high_part as u128) << 64 | reg128.low_part as u128; + } + let fpsr = unsafe { regs[32].value.reg64 } as u32; + let fpcr = unsafe { regs[33].value.reg64 } as u32; + Ok(CommonFpu { v, fpsr, fpcr }) + } + + fn set_fpu(&mut self, fpu: &CommonFpu) -> std::result::Result<(), RegisterError> { + let mut regs: Vec<_> = fpu + .v + .iter() + .enumerate() + .map(|(index, value)| hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_Q0 + index as u32, + value: hv_register_value { + reg128: hv_u128 { + low_part: *value as u64, + high_part: (*value >> 64) as u64, + }, + }, + ..Default::default() + }) + .collect(); + regs.extend([ + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_FPSR, + value: hv_register_value { + reg64: fpu.fpsr as u64, + }, + ..Default::default() + }, + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_FPCR, + value: hv_register_value { + reg64: fpu.fpcr as u64, + }, + ..Default::default() + }, + ]); + self.vcpu_fd + .set_reg(®s) + .map_err(|e| RegisterError::SetFpu(e.into())) + } + + fn sregs(&self) -> std::result::Result { + let mut regs = [ + hv_register_name_HV_ARM64_REGISTER_TTBR0_EL1, + hv_register_name_HV_ARM64_REGISTER_TCR_EL1, + hv_register_name_HV_ARM64_REGISTER_MAIR_EL1, + hv_register_name_HV_ARM64_REGISTER_SCTLR_EL1, + hv_register_name_HV_ARM64_REGISTER_CPACR_EL1, + hv_register_name_HV_ARM64_REGISTER_VBAR_EL1, + hv_register_name_HV_ARM64_REGISTER_SP_EL1, + ] + .map(|name| hv_register_assoc { + name, + ..Default::default() + }); + self.vcpu_fd + .get_reg(&mut regs) + .map_err(|e| RegisterError::GetRegs(e.into()))?; + + // SAFETY: Each requested register contains a 64-bit value. + Ok(unsafe { + CommonSpecialRegisters { + ttbr0_el1: regs[0].value.reg64, + tcr_el1: regs[1].value.reg64, + mair_el1: regs[2].value.reg64, + sctlr_el1: regs[3].value.reg64, + cpacr_el1: regs[4].value.reg64, + vbar_el1: regs[5].value.reg64, + sp_el1: regs[6].value.reg64, + } + }) + } + + fn set_sregs( + &mut self, + sregs: &CommonSpecialRegisters, + ) -> std::result::Result<(), RegisterError> { + let regs = [ + ( + hv_register_name_HV_ARM64_REGISTER_TTBR0_EL1, + sregs.ttbr0_el1, + ), + (hv_register_name_HV_ARM64_REGISTER_TCR_EL1, sregs.tcr_el1), + (hv_register_name_HV_ARM64_REGISTER_MAIR_EL1, sregs.mair_el1), + ( + hv_register_name_HV_ARM64_REGISTER_SCTLR_EL1, + sregs.sctlr_el1, + ), + ( + hv_register_name_HV_ARM64_REGISTER_CPACR_EL1, + sregs.cpacr_el1, + ), + (hv_register_name_HV_ARM64_REGISTER_VBAR_EL1, sregs.vbar_el1), + (hv_register_name_HV_ARM64_REGISTER_SP_EL1, sregs.sp_el1), + ] + .map(|(name, value)| hv_register_assoc { + name, + value: hv_register_value { reg64: value }, + ..Default::default() + }); + self.vcpu_fd + .set_reg(®s) + .map_err(|e| RegisterError::SetRegs(e.into())) + } + + fn debug_regs(&self) -> std::result::Result { + // Debug register support on aarch64 MSHV is not yet implemented + Ok(CommonDebugRegs::default()) + } + + fn set_debug_regs(&self, _drs: &CommonDebugRegs) -> std::result::Result<(), RegisterError> { + // Debug register support on aarch64 MSHV is not yet implemented + Ok(()) + } + + fn can_reset_vcpu(&self) -> bool { + true + } + + fn reset_vcpu(&mut self) -> Result<(), ResetVcpuError> { + let mut replacement = Self::new().map_err(|e| ResetVcpuError::Unknown(e.to_string()))?; + for region in &self.mapped_regions { + replacement + .vm_fd + .map_user_memory(*region) + .map_err(|e| ResetVcpuError::Hypervisor(e.into()))?; + } + replacement.mapped_regions.clone_from(&self.mapped_regions); + *self = replacement; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::mmio_write_info; + + #[test] + fn decodes_mmio_write_syndrome() { + assert_eq!(mmio_write_info(0x93c8_8047), Some((8, 8))); + } + + #[test] + fn rejects_invalid_mmio_write_syndrome() { + assert_eq!(mmio_write_info(0), None); + assert_eq!(mmio_write_info(1 << 24), None); + assert_eq!(mmio_write_info(1 << 6), None); } } diff --git a/src/hyperlight_host/src/mem/memory_region.rs b/src/hyperlight_host/src/mem/memory_region.rs index 75636a6c7..8c9e8cc3e 100644 --- a/src/hyperlight_host/src/mem/memory_region.rs +++ b/src/hyperlight_host/src/mem/memory_region.rs @@ -103,8 +103,16 @@ impl TryFrom for MemoryRegionFlags { impl TryFrom for MemoryRegionFlags { type Error = crate::HyperlightError; - fn try_from(_msg: hv_arm64_memory_intercept_message) -> crate::Result { - unimplemented!("try_from") + fn try_from(msg: hv_arm64_memory_intercept_message) -> crate::Result { + let access_type = msg.header.intercept_access_type; + match access_type { + 0 => Ok(MemoryRegionFlags::READ), + 1 => Ok(MemoryRegionFlags::WRITE), + 2 => Ok(MemoryRegionFlags::EXECUTE), + _ => Err(crate::HyperlightError::Error( + "unknown memory access type".to_string(), + )), + } } } diff --git a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs index 1dbade579..6c2aae84b 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/file/config.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/file/config.rs @@ -787,8 +787,11 @@ mod tests { "unrecognized x86_64 CPU vendor: {v:?}" ); #[cfg(all(target_arch = "aarch64", target_os = "linux"))] - // MIDR_EL1 implementer byte for Apple silicon. - assert_eq!(v, "0x61", "unexpected aarch64 CPU implementer"); + // MIDR_EL1 implementer bytes for Arm and Apple silicon. + assert!( + matches!(v, "0x41" | "0x61"), + "unexpected aarch64 CPU implementer: {v:?}" + ); #[cfg(all(target_arch = "aarch64", target_os = "macos"))] assert_eq!(v, "0x61", "unexpected aarch64 CPU implementer"); #[cfg(all(target_arch = "aarch64", target_os = "windows"))] diff --git a/src/hyperlight_host/tests/integration_test.rs b/src/hyperlight_host/tests/integration_test.rs index eca999e32..f88fd4081 100644 --- a/src/hyperlight_host/tests/integration_test.rs +++ b/src/hyperlight_host/tests/integration_test.rs @@ -1064,8 +1064,9 @@ fn interrupt_random_kill_stress_test() { use hyperlight_host::sandbox::snapshot::Snapshot; use tracing::{error, trace}; + // MSHV restore holds both old and replacement partitions under a 64-partition host limit. #[cfg(target_arch = "aarch64")] - const POOL_SIZE: usize = 64; + const POOL_SIZE: usize = if cfg!(mshv3) { 16 } else { 64 }; #[cfg(not(target_arch = "aarch64"))] const POOL_SIZE: usize = 100; const NUM_THREADS: usize = 100; @@ -1515,7 +1516,12 @@ fn interrupt_infinite_loop_stress_test() { use std::sync::{Arc, Barrier}; use std::thread; - const NUM_THREADS: usize = 50; + // MSHV restore needs a second partition per worker. WHP resets in place. + const NUM_THREADS: usize = if cfg!(all(target_arch = "aarch64", mshv3)) { + 16 + } else { + 50 + }; const ITERATIONS_PER_THREAD: usize = 500; let mut handles = vec![]; @@ -1600,10 +1606,9 @@ fn interrupt_infinite_moving_loop_stress_test() { use std::sync::Arc; use std::thread; - // ARM64 WHP partitions include GICv3 state, limiting practical partition - // concurrency. Each thread creates two partitions. + // Each worker holds two partitions. Keep headroom under MSHV's 64-partition host limit. #[cfg(target_arch = "aarch64")] - const NUM_THREADS: usize = 32; + const NUM_THREADS: usize = if cfg!(mshv3) { 8 } else { 32 }; #[cfg(not(target_arch = "aarch64"))] const NUM_THREADS: usize = 200; diff --git a/src/hyperlight_host/tests/sandbox_host_tests.rs b/src/hyperlight_host/tests/sandbox_host_tests.rs index bd7dc77de..6ade560dd 100644 --- a/src/hyperlight_host/tests/sandbox_host_tests.rs +++ b/src/hyperlight_host/tests/sandbox_host_tests.rs @@ -494,10 +494,19 @@ fn simple_test() { #[test] fn simple_test_parallel() { - let handles: Vec<_> = (0..50) - .map(|_| { - std::thread::spawn(|| { - simple_test_helper(); + const NUM_RUNS: usize = 50; + const NUM_THREADS: usize = if cfg!(all(target_arch = "aarch64", mshv3)) { + 24 + } else { + NUM_RUNS + }; + + let handles: Vec<_> = (0..NUM_THREADS) + .map(|worker| { + std::thread::spawn(move || { + for _ in (worker..NUM_RUNS).step_by(NUM_THREADS) { + simple_test_helper(); + } }) }) .collect(); @@ -907,7 +916,11 @@ fn callback_test_parallel() { #[cfg(all(target_arch = "aarch64", target_os = "windows"))] const THREADS: usize = 64; #[cfg(not(all(target_arch = "aarch64", target_os = "windows")))] - const THREADS: usize = 100; + const THREADS: usize = if cfg!(all(target_arch = "aarch64", mshv3)) { + 16 + } else { + 100 + }; let handles: Vec<_> = (0..THREADS) .map(|_| { diff --git a/typos.toml b/typos.toml index 8155b53e5..1c6cac99c 100644 --- a/typos.toml +++ b/typos.toml @@ -19,3 +19,7 @@ readables="readables" writables="writables" # itme is an acronym used in HVF support for "Into TiMoutable Error" itme="itme" + +[default.extend-identifiers] +# This spelling is part of the mshv-bindings API. +hv_partition_property_code_HV_PARTITION_PROPERTY_GITS_TRANSLATER_BASE_ADDRESS="hv_partition_property_code_HV_PARTITION_PROPERTY_GITS_TRANSLATER_BASE_ADDRESS" From 86cb5d5666735bd2d1f9a3fb0d61650240be72d3 Mon Sep 17 00:00:00 2001 From: cshung <3410332+cshung@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:03:26 -0800 Subject: [PATCH 2/3] feat(gdb): share ARM64 debugger support across backends Architecture adapters, software breakpoints, page-sized memory access and recovery handling are shared by backend layers. Guest debug vectors and common tests support ARM64. Backend layers control platform enablement. The commit-layer document defines dependencies and shared-update validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: cshung <3410332+cshung@users.noreply.github.com> --- CHANGELOG.md | 2 + Justfile | 2 +- docs/gdb-commit-layers.md | 54 ++ src/hyperlight_common/src/arch/aarch64/exn.rs | 3 + .../src/arch/aarch64/exception/entry.rs | 17 + .../examples/guest-debugging/main.rs | 203 ++++- .../src/hypervisor/gdb/arch.rs | 97 +-- .../src/hypervisor/gdb/arch/aarch64.rs | 27 + .../src/hypervisor/gdb/arch/x86_64.rs | 61 ++ .../src/hypervisor/gdb/event_loop.rs | 12 +- src/hyperlight_host/src/hypervisor/gdb/mod.rs | 166 +++- .../gdb/{x86_64_target.rs => target.rs} | 137 +++- .../src/hypervisor/hyperlight_vm/aarch64.rs | 75 +- .../src/hypervisor/hyperlight_vm/debug.rs | 725 ++++++++++++++++++ .../src/hypervisor/hyperlight_vm/mod.rs | 63 +- .../src/hypervisor/hyperlight_vm/x86_64.rs | 413 +--------- .../hypervisor/virtual_machine/kvm/x86_64.rs | 6 +- .../src/hypervisor/virtual_machine/mod.rs | 12 +- .../hypervisor/virtual_machine/mshv/x86_64.rs | 6 +- .../hypervisor/virtual_machine/whp/x86_64.rs | 6 +- src/hyperlight_host/src/mem/elf.rs | 26 +- src/hyperlight_host/src/mem/exe.rs | 4 + src/hyperlight_host/src/mem/mgr.rs | 4 +- .../src/sandbox/initialized.rs | 248 ++++++ .../src/sandbox/snapshot/memory/backing.rs | 2 +- .../src/sandbox/uninitialized_evolve.rs | 63 +- 26 files changed, 1873 insertions(+), 561 deletions(-) create mode 100644 docs/gdb-commit-layers.md create mode 100644 src/hyperlight_host/src/hypervisor/gdb/arch/aarch64.rs create mode 100644 src/hyperlight_host/src/hypervisor/gdb/arch/x86_64.rs rename src/hyperlight_host/src/hypervisor/gdb/{x86_64_target.rs => target.rs} (79%) create mode 100644 src/hyperlight_host/src/hypervisor/hyperlight_vm/debug.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f254b3c3..ff3e4a280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed * Fix restore on WHP not rewinding the guest TSC on hosts with `TSC_ADJUST` support. +* GDB memory reads and writes translate each guest page on hosts with larger pages. +* ARM64 GDB snapshot recovery handles cancellation and crash detach. Snapshot checkpointing rejects software breakpoints before replacing memory. ## [v0.19.0] - 2026-11-04 diff --git a/Justfile b/Justfile index 1916792f2..aea45bcf2 100644 --- a/Justfile +++ b/Justfile @@ -335,7 +335,7 @@ test-no-surrogate target=default-target: # runs tests that exercise gdb debugging test-rust-gdb-debugging target=default-target features="": {{ cargo-cmd }} test --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --example guest-debugging {{ if features =="" {'--features gdb'} else { "--features gdb," + features } }} - {{ cargo-cmd }} test --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib {{ if features =="" {'--features gdb'} else { "--features gdb," + features } }} -- hypervisor::gdb:: + {{ cargo-cmd }} test --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib {{ if features =="" {'--features gdb'} else { "--features gdb," + features } }} -- hypervisor::gdb:: hypervisor::hyperlight_vm::debug::tests:: gdb_recovery_tests:: # rust test for crashdump test-rust-crashdump target=default-target features="": diff --git a/docs/gdb-commit-layers.md b/docs/gdb-commit-layers.md new file mode 100644 index 000000000..56174181d --- /dev/null +++ b/docs/gdb-commit-layers.md @@ -0,0 +1,54 @@ +# GDB commit layers + +Each layer is one signed commit. Backend layers depend on shared GDB support, +not on another backend's debugger. + +```text +main +|-- A: shared GDB support +| |-- B: WHP ARM64 GDB +| `-- C: KVM ARM64 GDB +`-- D: ARM64 MSHV backend and CI + `-- E: cherry-pick of A + `-- F: MSHV ARM64 GDB +``` + +| Layer | Branch | Parent | Contents | +|---|---|---|---| +| A | `cshung/gdb-shared` | `main` | Architecture adapters, protocol handling, software breakpoints, page-sized memory access, guest debug vectors, snapshot recovery, common tests | +| B | `cshung/whp-aarch64-gdb` | A | WHP register access, HVC exits, debug registers, cache synchronization, platform enablement and tests | +| C | `cshung/kvm-aarch64-gdb` | A | KVM debug exits, debug registers, platform enablement and CI | +| D | `cshung/mshv-aarch64` | `main` | MSHV VM implementation, register access, reset, runner configuration and bounded CI scheduling | +| E | `cshung/mshv-aarch64-gdb-shared` | D | A's shared patch | +| F | `cshung/mshv-aarch64-gdb` | E | MSHV HVC exits, exception context, debug registers, platform enablement and tests | + +A retains the existing x86_64 GDB platform guard. B, C and F enable their ARM64 +backend. D and E provide an MSHV backend without ARM64 GDB enablement. +F rejects KVM debugging until C is integrated. + +## Updating layers + +* Update A for shared behavior. Rebuild B and C on A, cherry-pick A onto D as E, + then rebuild F on E. Validate all three backend tips before publication. +* Update B or C for a backend-only debugger change. The sibling branch is independent. +* Update D for MSHV backend or CI changes. Rebuild E and F on D. +* Update F for an MSHV-only debugger change. +* Keep A and E patch-equivalent. E's parent and commit ID differ from A's. +* Squash each layer to one commit. Keep historical tips in local archive branches + before rewriting published refs. Push with an explicit lease on each old remote tip. + +Run formatting and Clippy on each affected tip. Rebuild guests for guest changes. +Run the common debugger tests, backend tests and real GDB client tests on WHP, +KVM and MSHV for shared updates. Validate both the default Linux driver selection +and the backend-only feature configuration. A history-only rewrite can reuse +runtime results when the corresponding source files are identical. + +## Pull requests + +A has a shared-support PR. B uses #1907, C uses #2016, and D uses #1626. +F has an MSHV GDB PR. E is a cherry-pick, not a second shared-support PR. + +The branches live in `cshung/hyperlight`. Native GitHub stacks require head +branches in the upstream repository. These fork PRs target upstream `main` and +link their prerequisites and backend-only comparisons in their descriptions. +Their full PR diffs include unmerged prerequisites. diff --git a/src/hyperlight_common/src/arch/aarch64/exn.rs b/src/hyperlight_common/src/arch/aarch64/exn.rs index 648822460..f7ba7b996 100644 --- a/src/hyperlight_common/src/arch/aarch64/exn.rs +++ b/src/hyperlight_common/src/arch/aarch64/exn.rs @@ -3,6 +3,9 @@ use crate::vmem::bits; +/// HVC immediate for forwarding guest exceptions to the Hyper-V debugger. +pub const DEBUG_EXCEPTION_HVC: u16 = 0x485a; + const ESR_EC_UNKNOWN: u64 = 0b000000; // udf generates this const ESR_EC_DATA_ABORT_LOWER_EL: u64 = 0b100100; const ESR_EC_DATA_ABORT_SAME_EL: u64 = 0b100101; diff --git a/src/hyperlight_guest_bin/src/arch/aarch64/exception/entry.rs b/src/hyperlight_guest_bin/src/arch/aarch64/exception/entry.rs index 869c10bb1..7fb0bce3a 100644 --- a/src/hyperlight_guest_bin/src/arch/aarch64/exception/entry.rs +++ b/src/hyperlight_guest_bin/src/arch/aarch64/exception/entry.rs @@ -104,10 +104,24 @@ context_restore:\n q_off = const offset_of!(ExceptionContext, q), ); +// Only synchronous exceptions have a syndrome for debug forwarding. +// The host resumes debug events at ERET and other synchronous exceptions at the prologue. macro_rules! vbar_entry { ($et:literal, $ef:literal) => { concat!( " + .if {ExceptionType_", + $et, + "} == {ExceptionType_Synchronous}\n + stp x0, x1, [sp, #-16]!\n + mrs x0, mdscr_el1\n + tbz x0, #13, 1f\n + ldp x0, x1, [sp], #16\n + hvc #{debug_hvc}\n + eret\n + 1:\n + ldp x0, x1, [sp], #16\n + .endif\n sub sp, sp, #{ctx_size}\n str x30, [sp, #{x30_off}]\n bl context_save\n @@ -129,6 +143,8 @@ macro_rules! vbar_entry { global_asm!(" .balign 0x800\n .global vbar\n +.global hyperlight_debug_vectors\n +hyperlight_debug_vectors:\n vbar:\n", vbar_entry!("Synchronous", "CurrentSP0"), vbar_entry!("IRQ", "CurrentSP0"), @@ -148,6 +164,7 @@ vbar:\n", vbar_entry!("SError", "LowerAArch32"), ctx_size = const size_of::(), x30_off = const offset_of!(ExceptionContext, x) + 15 * 0x010, + debug_hvc = const hyperlight_common::arch::exn::DEBUG_EXCEPTION_HVC, handler = sym super::handle::handle_exception, ExceptionType_Synchronous = const ExceptionType::Synchronous as u64, ExceptionType_IRQ = const ExceptionType::Irq as u64, diff --git a/src/hyperlight_host/examples/guest-debugging/main.rs b/src/hyperlight_host/examples/guest-debugging/main.rs index 47a493a9b..cdb08d7e1 100644 --- a/src/hyperlight_host/examples/guest-debugging/main.rs +++ b/src/hyperlight_host/examples/guest-debugging/main.rs @@ -34,10 +34,12 @@ fn main() -> hyperlight_host::Result<()> { .host_function("Sleep5Secs", sleep_5_secs) .build()?; - // Call guest function + #[cfg(target_arch = "x86_64")] multi_use_sandbox_dbg .call::<()>("UseSSE2Registers", ()) .unwrap(); + #[cfg(target_arch = "aarch64")] + multi_use_sandbox_dbg.call::<()>("NoOp", ()).unwrap(); let message = "Hello, World! I am executing inside of a VM with debugger attached :)\n".to_string(); @@ -291,6 +293,205 @@ mod tests { } #[test] + #[cfg(target_arch = "aarch64")] + #[serial] + fn test_gdb_bootstrap_step_preserves_live_stack() { + let (out_file_path, cmd_file_path, manifest_dir) = + gdb_test_paths("gdb-aarch64-bootstrap-stack"); + let cmd = format!( + "set osabi none + file {manifest_dir}/../tests/rust_guests/bin/debug/simpleguest + target remote :8080 + set pagination off + set logging file {out_file_path} + set logging enabled on + set language c + set $entry = $pc + set $instruction = *(unsigned int *)$entry + set *(unsigned int *)$entry = 0xd503201f + set $bootstrap_sp = $sp + set $canary = $sp - 16 + set $saved = *(unsigned long long *)$canary + set *(unsigned long long *)$canary = 0x123456789abcdef0 + set $sp = $sp - 32 + stepi + if *(unsigned long long *)$canary != 0x123456789abcdef0 + echo Bootstrap debug step overwrote the live stack\\n + quit 1 + end + set *(unsigned long long *)$canary = $saved + set $sp = $bootstrap_sp + set *(unsigned int *)$entry = $instruction + set $pc = $entry + echo Bootstrap debug step preserved the live stack\\n + set logging enabled off + detach + quit + " + ); + let result = run_guest_and_gdb(&cmd_file_path, &out_file_path, &cmd, |contents| { + contents.contains("Bootstrap debug step preserved the live stack") + }); + cleanup(&out_file_path, &cmd_file_path); + assert!(result.is_ok(), "{}", result.unwrap_err()); + } + + #[test] + #[cfg(target_arch = "aarch64")] + #[serial] + fn test_gdb_continue_over_sw_breakpoint() { + let (out_file_path, cmd_file_path, manifest_dir) = gdb_test_paths("gdb-aarch64-continue"); + let cmd = format!( + "file {manifest_dir}/../tests/rust_guests/bin/debug/simpleguest + target remote :8080 + set pagination off + set logging file {out_file_path} + set logging enabled on + break simpleguest::no_op + commands 1 + continue + end + break simpleguest::print_output + commands 2 + echo Continued over ARM64 software breakpoint\\n + set logging enabled off + detach + quit + end + continue + " + ); + #[cfg(windows)] + let cmd = format!("set osabi none\n{cmd}"); + let checker = + |contents: String| contents.contains("Continued over ARM64 software breakpoint"); + let result = run_guest_and_gdb(&cmd_file_path, &out_file_path, &cmd, checker); + + cleanup(&out_file_path, &cmd_file_path); + assert!(result.is_ok(), "{}", result.unwrap_err()); + } + + #[test] + #[cfg(target_arch = "aarch64")] + #[serial] + fn test_gdb_hardware_breakpoint_and_step() { + let (out_file_path, cmd_file_path, manifest_dir) = gdb_test_paths("gdb-aarch64-hardware"); + let cmd = format!( + "set osabi none + file {manifest_dir}/../tests/rust_guests/bin/debug/simpleguest + target remote :8080 + set pagination off + set logging file {out_file_path} + set logging enabled on + set $entry_pc = $pc + stepi + if $pc != $entry_pc + 4 + echo Entry step did not execute one instruction\\n + quit 1 + end + hbreak simpleguest::no_op + continue + set $return_pc = $x30 + stepi + if $pc != $return_pc + echo Hardware RET step missed its destination\\n + quit 1 + end + echo Hardware breakpoint and entry/RET stepping passed\\n + set logging enabled off + detach + quit + " + ); + let checker = |contents: String| { + contents.contains("Hardware breakpoint and entry/RET stepping passed") + }; + let result = run_guest_and_gdb(&cmd_file_path, &out_file_path, &cmd, checker); + cleanup(&out_file_path, &cmd_file_path); + assert!(result.is_ok(), "{}", result.unwrap_err()); + } + + #[test] + #[cfg(target_arch = "aarch64")] + #[serial] + fn test_gdb_code_writes_and_self_loop() { + let (out_file_path, cmd_file_path, manifest_dir) = + gdb_test_paths("gdb-aarch64-code-writes"); + let cmd = format!( + "set osabi none + file {manifest_dir}/../tests/rust_guests/bin/debug/simpleguest + target remote :8080 + set pagination off + set logging file {out_file_path} + set logging enabled on + hbreak simpleguest::no_op + continue + delete 1 + set language c + set $loop_pc = $pc + set $instruction = *(unsigned int *)$pc + set *(unsigned int *)$pc = 0x14000000 + stepi + if $pc != $loop_pc + echo Self-loop step missed its destination\\n + quit 1 + end + set *(unsigned int *)$pc = $instruction + set $return_pc = $x30 + stepi + if $pc != $return_pc + echo Restored RET missed its destination\\n + quit 1 + end + echo Code writes and self-loop stepping passed\\n + set logging enabled off + detach + quit + " + ); + let checker = + |contents: String| contents.contains("Code writes and self-loop stepping passed"); + let result = run_guest_and_gdb(&cmd_file_path, &out_file_path, &cmd, checker); + cleanup(&out_file_path, &cmd_file_path); + assert!(result.is_ok(), "{}", result.unwrap_err()); + } + + #[test] + #[cfg(target_arch = "aarch64")] + #[serial] + fn test_gdb_single_step() { + let (out_file_path, cmd_file_path, manifest_dir) = gdb_test_paths("gdb-aarch64-step"); + let cmd = format!( + "file {manifest_dir}/../tests/rust_guests/bin/debug/simpleguest + target remote :8080 + set pagination off + set logging file {out_file_path} + set logging enabled on + break simpleguest::no_op + stepi + echo Stepped over ARM64 linear instruction\\n + continue + stepi + echo Stepped over ARM64 RET\\n + set logging enabled off + detach + quit + " + ); + #[cfg(windows)] + let cmd = format!("set osabi none\n{cmd}"); + let checker = |contents: String| { + contents.contains("Stepped over ARM64 linear instruction") + && contents.contains("Stepped over ARM64 RET") + }; + let result = run_guest_and_gdb(&cmd_file_path, &out_file_path, &cmd, checker); + + cleanup(&out_file_path, &cmd_file_path); + assert!(result.is_ok(), "{}", result.unwrap_err()); + } + + #[test] + #[cfg(target_arch = "x86_64")] #[serial] fn test_gdb_sse_check() { let (out_file_path, cmd_file_path, manifest_dir) = gdb_test_paths("gdb-sse"); diff --git a/src/hyperlight_host/src/hypervisor/gdb/arch.rs b/src/hyperlight_host/src/hypervisor/gdb/arch.rs index b4cfca9ea..e7cea6ca8 100644 --- a/src/hyperlight_host/src/hypervisor/gdb/arch.rs +++ b/src/hyperlight_host/src/hypervisor/gdb/arch.rs @@ -1,91 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 The Hyperlight Authors. -//! This file contains architecture specific code for the x86_64 - -use super::{DebugError, DebuggableVm, VcpuStopReason}; -use crate::hypervisor::regs::CommonRegisters; -use crate::hypervisor::virtual_machine::RegisterError; - -/// Errors that can occur when determining the vCPU stop reason -#[derive(Debug, thiserror::Error)] -pub enum VcpuStopReasonError { - #[error("Failed to get registers: {0}")] - GetRegs(#[from] RegisterError), - #[error("Failed to remove hardware breakpoint: {0}")] - RemoveHwBreakpoint(#[from] DebugError), -} - -// Described in Table 6-1. Exceptions and Interrupts at Page 6-13 Vol. 1 -// of Intel 64 and IA-32 Architectures Software Developer's Manual -/// Exception id for #DB -pub(crate) const DB_EX_ID: u32 = 1; -/// Exception id for #BP - triggered by the INT3 instruction -pub(crate) const BP_EX_ID: u32 = 3; - -/// Software Breakpoint size in memory -pub(crate) const SW_BP_SIZE: usize = 1; -/// Software Breakpoint opcode - INT3 -/// Check page 7-28 Vol. 3A of Intel 64 and IA-32 -/// Architectures Software Developer's Manual -pub(crate) const SW_BP_OP: u8 = 0xCC; -/// Software Breakpoint written to memory -pub(crate) const SW_BP: [u8; SW_BP_SIZE] = [SW_BP_OP]; -/// Maximum number of supported hardware breakpoints -pub(crate) const MAX_NO_OF_HW_BP: usize = 4; - -/// Check page 19-4 Vol. 3B of Intel 64 and IA-32 -/// Architectures Software Developer's Manual -/// Bit position of BS flag in DR6 debug register -pub(crate) const DR6_BS_FLAG_POS: usize = 14; -/// Bit mask of BS flag in DR6 debug register -pub(crate) const DR6_BS_FLAG_MASK: u64 = 1 << DR6_BS_FLAG_POS; -/// Bit position of HW breakpoints status in DR6 debug register -pub(crate) const DR6_HW_BP_FLAGS_POS: usize = 0; -/// Bit mask of HW breakpoints status in DR6 debug register -pub(crate) const DR6_HW_BP_FLAGS_MASK: u64 = 0x0F << DR6_HW_BP_FLAGS_POS; - -/// Determine the reason the vCPU stopped -/// This is done by checking the DR6 register and the exception id -pub(crate) fn vcpu_stop_reason( - vm: &dyn DebuggableVm, - dr6: u64, - exception: u32, -) -> std::result::Result { - let CommonRegisters { rip, .. } = vm.regs()?; - if DB_EX_ID == exception { - // If the BS flag in DR6 register is set, it means a single step - // instruction triggered the exit - // Check page 19-4 Vol. 3B of Intel 64 and IA-32 - // Architectures Software Developer's Manual - if dr6 & DR6_BS_FLAG_MASK != 0 { - return Ok(VcpuStopReason::DoneStep); - } - - // If any of the B0-B3 flags in DR6 register is set, it means a - // hardware breakpoint triggered the exit - // Check page 19-4 Vol. 3B of Intel 64 and IA-32 - // Architectures Software Developer's Manual - if DR6_HW_BP_FLAGS_MASK & dr6 != 0 { - return Ok(VcpuStopReason::HwBp); - } - } - - if BP_EX_ID == exception { - return Ok(VcpuStopReason::SwBp); - } - - // Log an error and provide internal debugging info - tracing::error!( - r"The vCPU exited because of an unknown reason: - rip: {:?} - dr6: {:?} - exception: {:?} - ", - rip, - dr6, - exception, - ); - - Ok(VcpuStopReason::Unknown) -} +#[cfg(target_arch = "aarch64")] +mod aarch64; +#[cfg(target_arch = "x86_64")] +mod x86_64; + +#[cfg(target_arch = "aarch64")] +pub(crate) use aarch64::*; +#[cfg(target_arch = "x86_64")] +pub(crate) use x86_64::*; diff --git a/src/hyperlight_host/src/hypervisor/gdb/arch/aarch64.rs b/src/hyperlight_host/src/hypervisor/gdb/arch/aarch64.rs new file mode 100644 index 000000000..1a5bda738 --- /dev/null +++ b/src/hyperlight_host/src/hypervisor/gdb/arch/aarch64.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +pub(crate) const SW_BP_SIZE: usize = 4; +pub(crate) const SW_BP: [u8; SW_BP_SIZE] = 0xd420_0000u32.to_le_bytes(); + +pub(crate) fn valid_sw_breakpoint_address(addr: u64) -> bool { + addr.is_multiple_of(SW_BP_SIZE as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn software_breakpoint_is_brk() { + assert_eq!(SW_BP, [0, 0, 0x20, 0xd4]); + } + + #[test] + fn software_breakpoints_require_instruction_alignment() { + assert!(valid_sw_breakpoint_address(0x4000)); + assert!(!valid_sw_breakpoint_address(0x4001)); + assert!(!valid_sw_breakpoint_address(0x4002)); + assert!(!valid_sw_breakpoint_address(0x4003)); + } +} diff --git a/src/hyperlight_host/src/hypervisor/gdb/arch/x86_64.rs b/src/hyperlight_host/src/hypervisor/gdb/arch/x86_64.rs new file mode 100644 index 000000000..d94783029 --- /dev/null +++ b/src/hyperlight_host/src/hypervisor/gdb/arch/x86_64.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 The Hyperlight Authors. + +use super::super::{DebugError, DebuggableVm, VcpuStopReason}; +use crate::hypervisor::regs::CommonRegisters; +use crate::hypervisor::virtual_machine::RegisterError; + +#[derive(Debug, thiserror::Error)] +pub enum VcpuStopReasonError { + #[error("Failed to get registers: {0}")] + GetRegs(#[from] RegisterError), + #[error("Failed to remove hardware breakpoint: {0}")] + RemoveHwBreakpoint(#[from] DebugError), +} + +pub(crate) const DB_EX_ID: u32 = 1; +pub(crate) const BP_EX_ID: u32 = 3; +pub(crate) const SW_BP_SIZE: usize = 1; +pub(crate) const SW_BP: [u8; SW_BP_SIZE] = [0xCC]; +pub(crate) const MAX_NO_OF_HW_BP: usize = 4; + +const DR6_BS_FLAG_MASK: u64 = 1 << 14; +const DR6_HW_BP_FLAGS_MASK: u64 = 0x0F; + +pub(crate) fn valid_sw_breakpoint_address(_addr: u64) -> bool { + true +} + +pub(crate) fn vcpu_stop_reason( + vm: &dyn DebuggableVm, + dr6: u64, + exception: u32, +) -> std::result::Result { + let CommonRegisters { rip, .. } = vm.regs()?; + if DB_EX_ID == exception { + if dr6 & DR6_BS_FLAG_MASK != 0 { + return Ok(VcpuStopReason::DoneStep); + } + + if DR6_HW_BP_FLAGS_MASK & dr6 != 0 { + return Ok(VcpuStopReason::HwBp); + } + } + + if BP_EX_ID == exception { + return Ok(VcpuStopReason::SwBp); + } + + tracing::error!( + r"The vCPU exited because of an unknown reason: + rip: {:?} + dr6: {:?} + exception: {:?} + ", + rip, + dr6, + exception, + ); + + Ok(VcpuStopReason::Unknown) +} diff --git a/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs b/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs index 766ec008d..83466bfac 100644 --- a/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs +++ b/src/hyperlight_host/src/hypervisor/gdb/event_loop.rs @@ -7,18 +7,22 @@ use gdbstub::stub::{ BaseStopReason, DisconnectReason, GdbStub, SingleThreadStopReason, run_blocking, }; -use super::x86_64_target::HyperlightSandboxTarget; +use super::target::HyperlightSandboxTarget; use super::{DebugResponse, GdbTargetError, VcpuStopReason}; // Signals are defined differently on Windows and Linux, so we use conditional compilation #[cfg(target_os = "linux")] mod signals { + #[cfg(target_arch = "aarch64")] + pub use libc::SIGTRAP; pub use libc::{SIGINT, SIGSEGV}; } #[cfg(windows)] mod signals { pub const SIGINT: i8 = 2; pub const SIGSEGV: i8 = 11; + #[cfg(target_arch = "aarch64")] + pub const SIGTRAP: i8 = 5; } struct GdbBlockingEventLoop; @@ -48,6 +52,11 @@ impl run_blocking::BlockingEventLoop for GdbBlockingEventLoop { VcpuStopReason::DoneStep => BaseStopReason::DoneStep, VcpuStopReason::SwBp => BaseStopReason::SwBreak(()), VcpuStopReason::HwBp => BaseStopReason::HwBreak(()), + #[cfg(target_arch = "aarch64")] + VcpuStopReason::Initial => BaseStopReason::SignalWithThread { + tid: (), + signal: Signal(signals::SIGTRAP as u8), + }, // This is a consequence of the GDB client sending an interrupt signal // to the target thread VcpuStopReason::Interrupt => BaseStopReason::SignalWithThread { @@ -58,6 +67,7 @@ impl run_blocking::BlockingEventLoop for GdbBlockingEventLoop { tid: (), signal: Signal(signals::SIGSEGV as u8), }, + #[cfg(target_arch = "x86_64")] VcpuStopReason::Unknown => { tracing::warn!("Unknown stop reason received"); diff --git a/src/hyperlight_host/src/hypervisor/gdb/mod.rs b/src/hyperlight_host/src/hypervisor/gdb/mod.rs index 29380a78b..4904472d7 100644 --- a/src/hyperlight_host/src/hypervisor/gdb/mod.rs +++ b/src/hyperlight_host/src/hypervisor/gdb/mod.rs @@ -3,7 +3,7 @@ pub(crate) mod arch; mod event_loop; -mod x86_64_target; +mod target; use std::io::{self, ErrorKind}; use std::net::TcpListener; @@ -15,8 +15,8 @@ use event_loop::event_loop_thread; use gdbstub::conn::ConnectionExt; use gdbstub::stub::GdbStub; use gdbstub::target::TargetError; +use target::HyperlightSandboxTarget; use thiserror::Error; -use x86_64_target::HyperlightSandboxTarget; use super::InterruptHandle; use super::regs::CommonRegisters; @@ -79,6 +79,9 @@ pub enum DebugMemoryAccessError { TranslateGuestAddress(u64), #[error("Failed to write to read-only region")] WriteToReadOnly, + #[cfg(all(target_arch = "aarch64", target_os = "windows"))] + #[error("Failed to flush the host instruction cache: {0}")] + FlushInstructionCache(String), } impl<'a> DebugMemoryView<'a> { @@ -96,6 +99,37 @@ impl<'a> DebugMemoryView<'a> { self.mem_mgr.layout.get_guest_code_gva() as u64 } + #[cfg(all(any(kvm, mshv3), target_arch = "aarch64"))] + pub(crate) fn translate_gva(&self, gva: u64, root_pt: u64) -> Result { + use crate::sandbox::snapshot::PageTableReader; + + self.mem_mgr + .with_guest_memory(&self.guest_mmap_regions, |memory| { + let reader = PageTableReader::new(memory, root_pt); + // SAFETY: Debug requests run with the vCPU stopped. The reader bounds-checks table accesses. + let mapping = + unsafe { hyperlight_common::vmem::virt_to_phys(&reader, gva, 1) }.next(); + reader.finish()?; + let gpa = mapping + .filter(|mapping| { + matches!( + mapping.kind, + hyperlight_common::vmem::MappingKind::Basic(_) + | hyperlight_common::vmem::MappingKind::Cow(_) + ) + }) + .and_then(|mapping| { + let offset = gva.checked_sub(mapping.virt_base)?; + (offset < mapping.len) + .then(|| mapping.phys_base.checked_add(offset)) + .flatten() + }) + .filter(|&gpa| memory.resolve(gpa, 1).is_some()); + gpa.ok_or_else(|| crate::new_error!("GVA is not mapped: {gva:#x}")) + }) + .map_err(|_| DebugError::TranslateGva(gva)) + } + /// Reads memory from the guest's address space with a maximum length of a PAGE_SIZE /// /// # Arguments @@ -153,16 +187,61 @@ impl<'a> DebugMemoryView<'a> { BackingSource::Dynamic(_) => Err(DebugMemoryAccessError::WriteToReadOnly), } } + + pub(crate) fn flush_host_instruction_cache( + &self, + gpa: u64, + len: usize, + ) -> std::result::Result<(), DebugMemoryAccessError> { + #[cfg(target_arch = "aarch64")] + { + #[cfg(target_os = "windows")] + use windows::Win32::System::Diagnostics::Debug::FlushInstructionCache; + #[cfg(target_os = "windows")] + use windows::Win32::System::Threading::GetCurrentProcess; + + let range = self + .mem_mgr + .with_guest_memory(&self.guest_mmap_regions, |memory| { + memory.host_range(gpa, len) + }) + .map_err(|e| DebugMemoryAccessError::CopyFailed(Box::new(e)))?; + + #[cfg(target_os = "windows")] + // SAFETY: The current process handle is valid and the range belongs to a live sandbox mapping. + unsafe { + FlushInstructionCache(GetCurrentProcess(), Some(range.start as *const _), len) + .map_err(|e| DebugMemoryAccessError::FlushInstructionCache(e.to_string()))?; + } + + #[cfg(target_os = "linux")] + { + unsafe extern "C" { + fn __clear_cache(begin: *mut u8, end: *mut u8); + } + // SAFETY: The range is checked against the live sandbox backing. The vCPU is stopped. + unsafe { __clear_cache(range.start as *mut u8, range.end as *mut u8) }; + } + } + + #[cfg(not(target_arch = "aarch64"))] + let _ = (gpa, len); + + Ok(()) + } } /// Defines the possible reasons for which a vCPU can be stopped when debugging -#[derive(Debug)] +#[derive(Clone, Copy, Debug)] pub enum VcpuStopReason { Crash, DoneStep, HwBp, + #[cfg(target_arch = "aarch64")] + Initial, SwBp, Interrupt, + #[cfg(target_arch = "x86_64")] Unknown, } @@ -218,6 +297,10 @@ pub enum DebugError { Register(#[from] RegisterError), #[error("Maximum hardware breakpoints ({0}) exceeded")] TooManyHwBreakpoints(usize), + #[error("Debug capability is not supported: {0}")] + Unsupported(&'static str), + #[error("Instruction cache synchronization failed: {0}")] + InstructionCacheSync(String), #[error("Translation of guest virtual address failed: {0}")] TranslateGva(u64), } @@ -226,20 +309,38 @@ pub enum DebugError { /// This extends the base VirtualMachine trait with GDB-specific functionality. pub(crate) trait DebuggableVm: VirtualMachine { /// Translates a guest virtual address to a guest physical address - fn translate_gva(&self, gva: u64) -> std::result::Result; + fn translate_gva( + &self, + gva: u64, + memory: &DebugMemoryView<'_>, + ) -> std::result::Result; /// Enable/disable debugging fn set_debug(&mut self, enable: bool) -> std::result::Result<(), DebugError>; /// Enable/disable single stepping - fn set_single_step(&mut self, enable: bool) -> std::result::Result<(), DebugError>; + fn set_single_step(&mut self, enable: bool) -> std::result::Result<(), DebugError> { + if enable { + Err(DebugError::Unsupported("single-step")) + } else { + Ok(()) + } + } /// Add a hardware breakpoint at the given address. /// Must be idempotent. - fn add_hw_breakpoint(&mut self, addr: u64) -> std::result::Result<(), DebugError>; + fn add_hw_breakpoint(&mut self, _addr: u64) -> std::result::Result<(), DebugError> { + Err(DebugError::Unsupported("hardware breakpoints")) + } /// Remove a hardware breakpoint at the given address - fn remove_hw_breakpoint(&mut self, addr: u64) -> std::result::Result<(), DebugError>; + fn remove_hw_breakpoint(&mut self, _addr: u64) -> std::result::Result<(), DebugError> { + Err(DebugError::Unsupported("hardware breakpoints")) + } + + fn sync_instruction_cache(&mut self) -> std::result::Result<(), DebugError> { + Ok(()) + } } /// Debug communication channel that is used for sending a request type and @@ -307,7 +408,7 @@ pub(crate) fn create_gdb_thread( let mut target = HyperlightSandboxTarget::new(hyp_conn); - // Waits for vCPU to stop at entrypoint breakpoint + // Wait for the vCPU interrupt handle. let msg = target.recv()?; if let DebugResponse::InterruptHandle(handle) = msg { tracing::info!("Received interrupt handle: {:?}", handle); @@ -316,7 +417,7 @@ pub(crate) fn create_gdb_thread( return Err(GdbTargetError::UnexpectedMessage); } - // Waits for vCPU to stop at entrypoint breakpoint + // Wait for the architecture-specific initial stop. let msg = target.recv()?; if let DebugResponse::VcpuStopped(_) = msg { event_loop_thread(debugger, &mut target); @@ -450,6 +551,53 @@ mod tests { } } + #[test] + #[cfg(all(any(kvm, mshv3), target_arch = "aarch64"))] + fn translate_gva_resolves_page_offsets_and_rejects_missing_tables() -> crate::Result<()> { + use hyperlight_common::vmem::PAGE_SIZE; + + let memory = TestMemory::new()?; + let access = memory.access(); + let root = hyperlight_common::layout::scratch_base_gpa( + memory.mem_mgr.layout.get_scratch_size(), + ); + let mut tables = [0u8; 4 * PAGE_SIZE]; + for level in 0..3 { + let entry = (root + (level as u64 + 1) * PAGE_SIZE as u64) | 3; + tables[level * PAGE_SIZE..level * PAGE_SIZE + 8] + .copy_from_slice(&entry.to_le_bytes()); + } + tables[3 * PAGE_SIZE + 8..3 * PAGE_SIZE + 16] + .copy_from_slice(&(SandboxMemoryLayout::BASE_ADDRESS as u64 | 3).to_le_bytes()); + tables[3 * PAGE_SIZE + 16..3 * PAGE_SIZE + 24] + .copy_from_slice(&((1u64 << 2) | (1 << 8)).to_le_bytes()); + access.write(&tables, root).unwrap(); + let code = PAGE_SIZE as u64; + for offset in [0, 1, PAGE_SIZE as u64 - 1] { + assert_eq!( + access.translate_gva(code + offset, root).unwrap(), + SandboxMemoryLayout::BASE_ADDRESS as u64 + offset + ); + } + assert!(matches!( + access.translate_gva(0, root), + Err(DebugError::TranslateGva(0)) + )); + assert!(matches!( + access.translate_gva(code * 2, root), + Err(DebugError::TranslateGva(_)) + )); + assert!(matches!( + access.translate_gva(code | (1 << 48), root), + Err(DebugError::TranslateGva(_)) + )); + assert!(matches!( + access.translate_gva(code, !0xfff), + Err(DebugError::TranslateGva(_)) + )); + Ok(()) + } + #[test] fn test_mem_access_read_single_byte() -> crate::Result<()> { let mut memory = TestMemory::new()?; diff --git a/src/hyperlight_host/src/hypervisor/gdb/x86_64_target.rs b/src/hyperlight_host/src/hypervisor/gdb/target.rs similarity index 79% rename from src/hyperlight_host/src/hypervisor/gdb/x86_64_target.rs rename to src/hyperlight_host/src/hypervisor/gdb/target.rs index e67401f0d..98be27aac 100644 --- a/src/hyperlight_host/src/hypervisor/gdb/x86_64_target.rs +++ b/src/hyperlight_host/src/hypervisor/gdb/target.rs @@ -16,6 +16,9 @@ use gdbstub::target::ext::breakpoints::{ }; use gdbstub::target::ext::section_offsets::{Offsets, SectionOffsets}; use gdbstub::target::{Target, TargetError, TargetResult}; +#[cfg(target_arch = "aarch64")] +use gdbstub_arch::aarch64::AArch64 as GdbTargetArch; +#[cfg(target_arch = "x86_64")] use gdbstub_arch::x86::X86_64_SSE as GdbTargetArch; use super::{DebugCommChannel, DebugMsg, DebugResponse, GdbTargetError}; @@ -69,9 +72,7 @@ impl HyperlightSandboxTarget { match self.send_command(DebugMsg::Continue)? { DebugResponse::Continue => Ok(()), DebugResponse::NotAllowed => { - tracing::error!("Action not allowed at this time, crash might have occurred"); - // This is a consequence of the target crashing or being in an invalid state - // we cannot continue execution, but we can still read registers and memory + tracing::error!("Resume is not allowed in the current target state"); Ok(()) } msg => { @@ -108,7 +109,8 @@ impl HyperlightSandboxTarget { /// Interrupts the vCPU execution pub(crate) fn interrupt_vcpu(&mut self) -> bool { if let Some(handle) = &self.interrupt_handle { - handle.kill_from_debugger() + handle.kill_from_debugger(); + true } else { tracing::warn!("No interrupt handle set, cannot interrupt vCPU"); @@ -198,27 +200,40 @@ impl SingleThreadBase for HyperlightSandboxTarget { match self.send_command(DebugMsg::ReadRegisters)? { DebugResponse::ReadRegisters(boxed_regs) => { let (read_regs, read_fpu) = boxed_regs.as_ref(); - regs.regs[0] = read_regs.rax; - regs.regs[1] = read_regs.rbp; - regs.regs[2] = read_regs.rcx; - regs.regs[3] = read_regs.rdx; - regs.regs[4] = read_regs.rsi; - regs.regs[5] = read_regs.rdi; - regs.regs[6] = read_regs.rbp; - regs.regs[7] = read_regs.rsp; - regs.regs[8] = read_regs.r8; - regs.regs[9] = read_regs.r9; - regs.regs[10] = read_regs.r10; - regs.regs[11] = read_regs.r11; - regs.regs[12] = read_regs.r12; - regs.regs[13] = read_regs.r13; - regs.regs[14] = read_regs.r14; - regs.regs[15] = read_regs.r15; - regs.rip = read_regs.rip; - regs.eflags = read_regs.rflags as u32; - - regs.xmm = read_fpu.xmm.map(u128::from_le_bytes); - regs.mxcsr = read_fpu.mxcsr; + #[cfg(target_arch = "x86_64")] + { + regs.regs[0] = read_regs.rax; + regs.regs[1] = read_regs.rbp; + regs.regs[2] = read_regs.rcx; + regs.regs[3] = read_regs.rdx; + regs.regs[4] = read_regs.rsi; + regs.regs[5] = read_regs.rdi; + regs.regs[6] = read_regs.rbp; + regs.regs[7] = read_regs.rsp; + regs.regs[8] = read_regs.r8; + regs.regs[9] = read_regs.r9; + regs.regs[10] = read_regs.r10; + regs.regs[11] = read_regs.r11; + regs.regs[12] = read_regs.r12; + regs.regs[13] = read_regs.r13; + regs.regs[14] = read_regs.r14; + regs.regs[15] = read_regs.r15; + regs.rip = read_regs.rip; + regs.eflags = read_regs.rflags as u32; + + regs.xmm = read_fpu.xmm.map(u128::from_le_bytes); + regs.mxcsr = read_fpu.mxcsr; + } + #[cfg(target_arch = "aarch64")] + { + regs.x = read_regs.x; + regs.sp = read_regs.sp; + regs.pc = read_regs.pc; + regs.cpsr = read_regs.pstate as u32; + regs.v = read_fpu.v; + regs.fpcr = read_fpu.fpcr; + regs.fpsr = read_fpu.fpsr; + } Ok(()) } @@ -240,6 +255,7 @@ impl SingleThreadBase for HyperlightSandboxTarget { ) -> TargetResult<(), Self> { tracing::debug!("Write regs"); + #[cfg(target_arch = "x86_64")] let common_regs = CommonRegisters { rax: regs.regs[0], rbx: regs.regs[1], @@ -261,17 +277,35 @@ impl SingleThreadBase for HyperlightSandboxTarget { rflags: u64::from(regs.eflags), }; + #[cfg(target_arch = "x86_64")] let mut xmm = [[0u8; 16]; 16]; + #[cfg(target_arch = "x86_64")] for (i, ®) in regs.xmm.iter().enumerate() { xmm[i] = reg.to_le_bytes(); } + #[cfg(target_arch = "x86_64")] let common_fpu = CommonFpu { xmm, mxcsr: regs.mxcsr, ..Default::default() }; + #[cfg(target_arch = "aarch64")] + let common_regs = CommonRegisters { + x: regs.x, + sp: regs.sp, + pc: regs.pc, + pstate: u64::from(regs.cpsr), + }; + + #[cfg(target_arch = "aarch64")] + let common_fpu = CommonFpu { + v: regs.v, + fpcr: regs.fpcr, + fpsr: regs.fpsr, + }; + match self.send_command(DebugMsg::WriteRegisters(Box::new(( common_regs, common_fpu, @@ -476,6 +510,9 @@ impl SingleThreadSingleStep for HyperlightSandboxTarget { #[cfg(test)] mod tests { + #[cfg(target_arch = "aarch64")] + use gdbstub_arch::aarch64::reg::AArch64CoreRegs; + #[cfg(target_arch = "x86_64")] use gdbstub_arch::x86::reg::X86_64CoreRegs; use super::*; @@ -492,6 +529,9 @@ mod tests { let res = gdb_conn.send(msg); assert!(res.is_ok()); + #[cfg(target_arch = "aarch64")] + let mut regs = AArch64CoreRegs::default(); + #[cfg(target_arch = "x86_64")] let mut regs = X86_64CoreRegs::default(); assert!( target.read_registers(&mut regs).is_ok(), @@ -515,4 +555,51 @@ mod tests { expected to fail" ); } + + #[cfg(target_arch = "aarch64")] + #[test] + fn aarch64_register_conversion_round_trip() { + let mut common_regs = CommonRegisters::default(); + for (index, value) in common_regs.x.iter_mut().enumerate() { + *value = 0x1000 + index as u64; + } + common_regs.sp = 0x2000; + common_regs.pc = 0x3000; + common_regs.pstate = 0xa000_03c5; + + let mut common_fpu = CommonFpu::default(); + for (index, value) in common_fpu.v.iter_mut().enumerate() { + *value = 0x4000 + index as u128; + } + common_fpu.fpcr = 0x5000; + common_fpu.fpsr = 0x6000; + + let (gdb_conn, hyp_conn) = DebugCommChannel::unbounded(); + let mut target = HyperlightSandboxTarget::new(hyp_conn); + gdb_conn + .send(DebugResponse::ReadRegisters(Box::new(( + common_regs, + common_fpu, + )))) + .unwrap(); + + let mut gdb_regs = AArch64CoreRegs::default(); + assert!(target.read_registers(&mut gdb_regs).is_ok()); + assert_eq!(gdb_regs.x, common_regs.x); + assert_eq!(gdb_regs.sp, common_regs.sp); + assert_eq!(gdb_regs.pc, common_regs.pc); + assert_eq!(gdb_regs.cpsr, common_regs.pstate as u32); + assert_eq!(gdb_regs.v, common_fpu.v); + assert_eq!(gdb_regs.fpcr, common_fpu.fpcr); + assert_eq!(gdb_regs.fpsr, common_fpu.fpsr); + assert!(matches!(gdb_conn.recv().unwrap(), DebugMsg::ReadRegisters)); + + gdb_conn.send(DebugResponse::WriteRegisters).unwrap(); + assert!(target.write_registers(&gdb_regs).is_ok()); + let DebugMsg::WriteRegisters(written) = gdb_conn.recv().unwrap() else { + panic!("unexpected request"); + }; + assert_eq!(written.0, common_regs); + assert_eq!(written.1, common_fpu); + } } diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs index f9a38c4ec..17c317c44 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/aarch64.rs @@ -5,6 +5,8 @@ use std::sync::Arc; +#[cfg(gdb)] +use super::SoftwareBreakpoints; use super::{ AccessPageTableError, CreateHyperlightVmError, DispatchGuestCallError, HyperlightVm, InitializeError, @@ -17,9 +19,11 @@ use crate::hypervisor::LinuxInterruptHandle; #[cfg(target_os = "windows")] use crate::hypervisor::WindowsInterruptHandle; #[cfg(gdb)] -use crate::hypervisor::gdb::{DebugCommChannel, DebugMsg, DebugResponse}; +use crate::hypervisor::gdb::{DebugCommChannel, DebugMsg, DebugResponse, DebuggableVm}; use crate::hypervisor::hyperlight_vm::get_guest_log_filter; use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; +#[cfg(not(gdb))] +use crate::hypervisor::virtual_machine::VirtualMachine; #[cfg(hvf)] use crate::hypervisor::virtual_machine::hvf::HvfVm; #[cfg(kvm)] @@ -27,8 +31,7 @@ use crate::hypervisor::virtual_machine::kvm::KvmVm; #[cfg(target_os = "windows")] use crate::hypervisor::virtual_machine::whp::WhpVm; use crate::hypervisor::virtual_machine::{ - HypervisorType, RegisterError, ResetVcpuError, VirtualMachine, VmError, - get_available_hypervisor, + HypervisorType, RegisterError, ResetVcpuError, VmError, get_available_hypervisor, }; use crate::mem::mgr::SandboxMemoryManager; use crate::mem::shared_mem::{GuestSharedMemory, HostSharedMemory}; @@ -39,6 +42,36 @@ use crate::sandbox::snapshot::{NextAction, SnapshotMemoryBacking}; use crate::sandbox::trace::MemTraceInfo; impl HyperlightVm { + #[cfg(gdb)] + pub(crate) fn initialize_debug_vectors( + &mut self, + vectors: Option, + ) -> Result<(), CreateHyperlightVmError> { + if self.gdb_conn.is_none() || !matches!(self.next_action, NextAction::Initialise(_)) { + return Ok(()); + } + #[cfg(kvm)] + if matches!(get_available_hypervisor(), Some(HypervisorType::Kvm)) { + return Ok(()); + } + let vectors = vectors.ok_or(VmError::Debug( + crate::hypervisor::gdb::DebugError::Unsupported( + "guest binary lacks ARM64 debug vectors", + ), + ))?; + let mut regs = self.vm.sregs().map_err(VmError::Register)?; + regs.vbar_el1 = vectors; + regs.sp_el1 = hyperlight_common::layout::SCRATCH_TOP_GVA as u64 + - hyperlight_common::layout::SCRATCH_TOP_EXN_STACK_OFFSET + + 1; + if self.rsp_gva == regs.sp_el1 { + // Reserve the forwarding spill above the live bootstrap stack. + self.rsp_gva -= 16; + } + self.vm.set_sregs(®s).map_err(VmError::Register)?; + Ok(()) + } + #[allow(clippy::too_many_arguments)] #[cfg_attr(target_os = "macos", allow(unused))] pub(crate) fn new( @@ -49,10 +82,12 @@ impl HyperlightVm { rsp_gva: u64, page_size: usize, #[cfg_attr(target_os = "windows", allow(unused_variables))] config: &SandboxConfiguration, - #[cfg(gdb)] _gdb_conn: Option>, + #[cfg(gdb)] gdb_conn: Option>, #[cfg(feature = "mem_profile")] _trace_info: MemTraceInfo, ) -> std::result::Result { - // TODO: support gdb on aarch64 + #[cfg(gdb)] + type VmType = Box; + #[cfg(not(gdb))] type VmType = Box; #[cfg(hvf)] let interrupt_handle: Arc = @@ -104,9 +139,26 @@ impl HyperlightVm { vm_can_reset_vcpu, pending_tlb_flush: false, + + #[cfg(gdb)] + gdb_conn, + #[cfg(gdb)] + sw_breakpoints: SoftwareBreakpoints::default(), + #[cfg(gdb)] + pending_software_step: None, + #[cfg(gdb)] + initial_debug_stop_pending: false, }; ret.update_snapshot_mapping(snapshot_mem)?; ret.update_scratch_mapping(scratch_mem)?; + + #[cfg(gdb)] + if ret.gdb_conn.is_some() { + ret.send_dbg_msg(DebugResponse::InterruptHandle(ret.interrupt_handle.clone()))?; + ret.vm.set_debug(true).map_err(VmError::Debug)?; + ret.initial_debug_stop_pending = true; + } + Ok(ret) } @@ -136,6 +188,10 @@ impl HyperlightVm { }; self.vm.set_regs(®s)?; + #[cfg(gdb)] + self.handle_initial_debug_stop(mem_mgr) + .map_err(super::RunVmError::DebugHandler)?; + self.run(mem_mgr, host_funcs) .map_err(InitializeError::Run)?; @@ -173,6 +229,9 @@ impl HyperlightVm { self.vm .set_fpu(&CommonFpu::default()) .map_err(DispatchGuestCallError::SetupRegs)?; + #[cfg(gdb)] + self.handle_initial_debug_stop(mem_mgr) + .map_err(super::RunVmError::DebugHandler)?; let result = self .run(mem_mgr, host_funcs) .map_err(DispatchGuestCallError::Run); @@ -201,6 +260,12 @@ impl HyperlightVm { self.vm_can_reset_vcpu, "No fallback path for vcpu reset on aarch64" ); + #[cfg(gdb)] + if self.gdb_conn.is_some() { + self.vm + .set_single_step(false) + .map_err(|error| ResetVcpuError::Unknown(error.to_string()))?; + } self.vm.reset_vcpu()?; self.apply_sregs(cr3, sregs)?; Ok(()) diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/debug.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/debug.rs new file mode 100644 index 000000000..c675d65ba --- /dev/null +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/debug.rs @@ -0,0 +1,725 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 The Hyperlight Authors. + +use std::collections::HashMap; + +use hyperlight_common::vmem::PAGE_SIZE; + +use super::{ + HandleDebugError, HyperlightVm, RecvDbgMsgError, SendDbgMsgError, VcpuStopReason, VmError, +}; +use crate::hypervisor::gdb::arch::{SW_BP, SW_BP_SIZE, valid_sw_breakpoint_address}; +use crate::hypervisor::gdb::{ + DebugError, DebugMemoryAccessError, DebugMemoryView, DebugMsg, DebugResponse, +}; + +#[derive(Debug, Default)] +pub(crate) struct SoftwareBreakpoints(HashMap>); + +impl SoftwareBreakpoints { + pub(super) fn contains(&self, gva: u64) -> bool { + self.0.contains_key(&gva) + } + + pub(super) fn get(&self, gva: u64) -> Option<&[u8]> { + self.0.get(&gva).map(Vec::as_slice) + } + + pub(super) fn insert(&mut self, gva: u64, instruction: Vec) { + self.0.insert(gva, instruction); + } + + pub(super) fn remove(&mut self, gva: u64) { + self.0.remove(&gva); + } + + fn addresses(&self) -> Vec { + self.0.keys().copied().collect() + } +} + +#[cfg(target_arch = "aarch64")] +#[derive(Clone, Debug)] +pub(super) struct PendingSoftwareStep { + origin: u64, + report_stop: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum ProcessDebugRequestError { + #[error("Debug is not enabled")] + DebugNotEnabled, + #[error("VM operation error: {0}")] + Vm(#[from] VmError), + #[error("Debug operation error: {0}")] + Debug(#[from] DebugError), + #[error("Software breakpoint address {address:#x} is not {alignment}-byte aligned")] + UnalignedSwBreakpoint { address: u64, alignment: usize }, + #[error("Address {0:#x} is not a software breakpoint")] + SwBreakpointNotFound(u64), + #[error("Failed to read memory: {0}")] + ReadMemory(#[from] DebugMemoryAccessError), + #[error("Failed to write memory: {0}")] + WriteMemory(DebugMemoryAccessError), +} + +impl HyperlightVm { + #[cfg(target_arch = "aarch64")] + pub(crate) fn has_sw_breakpoints(&self) -> bool { + !self.sw_breakpoints.0.is_empty() + } + + /// Discard software debug state when poisoned guest memory will be replaced. + #[cfg(target_arch = "aarch64")] + pub(crate) fn discard_software_breakpoints(&mut self) { + self.sw_breakpoints.0.clear(); + self.pending_software_step = None; + } + + pub(super) fn handle_debug( + &mut self, + mem_mgr: &crate::mem::mgr::SandboxMemoryManager, + stop_reason: VcpuStopReason, + ) -> std::result::Result<(), HandleDebugError> { + if self.gdb_conn.is_none() { + return Err(HandleDebugError::DebugNotEnabled); + } + + let mem_access = + DebugMemoryView::new(mem_mgr, self.get_mapped_regions().cloned().collect()); + + match stop_reason { + VcpuStopReason::Crash => { + self.send_dbg_msg(DebugResponse::VcpuStopped(stop_reason))?; + + loop { + tracing::debug!("Debug wait for event to resume vCPU"); + let req = self.recv_dbg_msg()?; + let mut deny_continue = false; + let mut detach = false; + + let response = match req { + DebugMsg::DisableDebug => { + detach = true; + #[cfg(target_arch = "aarch64")] + match self.process_dbg_request(DebugMsg::DisableDebug, &mem_access) { + Ok(response) => response, + Err(error) => { + tracing::error!("Failed to clean up after detach: {error}"); + self.vm + .set_debug(false) + .map_err(ProcessDebugRequestError::from)?; + self.discard_software_breakpoints(); + DebugResponse::DisableDebug + } + } + #[cfg(not(target_arch = "aarch64"))] + DebugResponse::DisableDebug + } + DebugMsg::Continue => { + deny_continue = true; + DebugResponse::NotAllowed + } + DebugMsg::Step => { + deny_continue = true; + DebugResponse::NotAllowed + } + DebugMsg::AddHwBreakpoint(_) + | DebugMsg::AddSwBreakpoint(_) + | DebugMsg::RemoveHwBreakpoint(_) + | DebugMsg::RemoveSwBreakpoint(_) + | DebugMsg::WriteAddr(_, _) + | DebugMsg::WriteRegisters(_) => DebugResponse::NotAllowed, + _ => match self.process_dbg_request(req, &mem_access) { + Ok(response) => response, + Err(ProcessDebugRequestError::ReadMemory( + DebugMemoryAccessError::TranslateGuestAddress(_), + )) + | Err(ProcessDebugRequestError::Debug(DebugError::TranslateGva(_))) => { + DebugResponse::ErrorOccurred + } + Err(e) => { + tracing::error!("Error processing debug request: {:?}", e); + return Err(HandleDebugError::ProcessRequest(e)); + } + }, + }; + + self.send_dbg_msg(response)?; + + if deny_continue { + self.send_dbg_msg(DebugResponse::VcpuStopped(VcpuStopReason::Crash))?; + } + + if detach { + break; + } + } + } + _ => { + self.send_dbg_msg(DebugResponse::VcpuStopped(stop_reason))?; + + loop { + tracing::debug!("Debug wait for event to resume vCPU"); + let req = self.recv_dbg_msg()?; + let resume_requested = matches!(&req, DebugMsg::Continue); + let resume_requested = resume_requested || matches!(&req, DebugMsg::Step); + let disable_requested = matches!(&req, DebugMsg::DisableDebug); + + let response = match self.process_dbg_request(req, &mem_access) { + Ok(response) => response, + #[cfg(target_arch = "aarch64")] + Err(error) if resume_requested => { + tracing::error!("{error}"); + DebugResponse::NotAllowed + } + Err(ProcessDebugRequestError::ReadMemory( + DebugMemoryAccessError::TranslateGuestAddress(_), + )) + | Err(ProcessDebugRequestError::Debug(DebugError::TranslateGva(_))) => { + DebugResponse::ErrorOccurred + } + Err(error) if disable_requested => { + tracing::error!("{error}"); + DebugResponse::ErrorOccurred + } + Err(e) => return Err(HandleDebugError::ProcessRequest(e)), + }; + + let resume = matches!( + response, + DebugResponse::Continue | DebugResponse::DisableDebug + ); + let resume = resume || matches!(response, DebugResponse::Step); + let denied_resume = + resume_requested && matches!(response, DebugResponse::NotAllowed); + self.send_dbg_msg(response)?; + + if denied_resume { + self.send_dbg_msg(DebugResponse::VcpuStopped(stop_reason))?; + } + if resume { + break; + } + } + } + } + + Ok(()) + } + + #[cfg(target_arch = "aarch64")] + pub(super) fn handle_initial_debug_stop( + &mut self, + mem_mgr: &crate::mem::mgr::SandboxMemoryManager, + ) -> std::result::Result<(), HandleDebugError> { + if self.initial_debug_stop_pending { + self.initial_debug_stop_pending = false; + self.handle_debug(mem_mgr, VcpuStopReason::Initial)?; + } + Ok(()) + } + + pub(crate) fn process_dbg_request( + &mut self, + req: DebugMsg, + mem_access: &DebugMemoryView<'_>, + ) -> std::result::Result { + if self.gdb_conn.is_none() { + return Err(ProcessDebugRequestError::DebugNotEnabled); + } + + match req { + DebugMsg::AddHwBreakpoint(addr) => Ok(DebugResponse::AddHwBreakpoint( + self.vm + .add_hw_breakpoint(addr) + .inspect_err(|e| tracing::error!("Failed to add hw breakpoint: {:?}", e)) + .is_ok(), + )), + DebugMsg::AddSwBreakpoint(addr) => Ok(DebugResponse::AddSwBreakpoint( + self.add_sw_breakpoint(addr, mem_access) + .inspect_err(|e| tracing::error!("Failed to add sw breakpoint: {:?}", e)) + .is_ok(), + )), + DebugMsg::Continue => { + #[cfg(target_arch = "aarch64")] + { + let pc = self.vm.regs().map_err(VmError::Register)?.pc; + if self.sw_breakpoints.contains(pc) { + self.begin_software_step(false, mem_access)?; + } else { + self.vm.set_single_step(false)?; + } + } + #[cfg(target_arch = "x86_64")] + self.vm + .set_single_step(false) + .inspect_err(|e| tracing::error!("Failed to continue execution: {:?}", e))?; + Ok(DebugResponse::Continue) + } + DebugMsg::DisableDebug => { + #[cfg(target_arch = "aarch64")] + self.cancel_software_step(mem_access)?; + for address in self.sw_breakpoints.addresses() { + self.remove_sw_breakpoint(address, mem_access)?; + } + self.vm + .set_debug(false) + .inspect_err(|e| tracing::error!("Failed to disable debugging: {:?}", e))?; + Ok(DebugResponse::DisableDebug) + } + DebugMsg::GetCodeSectionOffset => Ok(DebugResponse::GetCodeSectionOffset( + mem_access.code_section_offset(), + )), + DebugMsg::ReadAddr(addr, len) => { + let mut data = vec![0u8; len]; + self.read_addrs(addr, &mut data, mem_access) + .inspect_err(|e| tracing::error!("Failed to read from address: {:?}", e))?; + Ok(DebugResponse::ReadAddr(data)) + } + DebugMsg::ReadRegisters => { + let regs = self.vm.regs().map_err(VmError::Register)?; + let fpu = self.vm.fpu().map_err(VmError::Register)?; + Ok(DebugResponse::ReadRegisters(Box::new((regs, fpu)))) + } + DebugMsg::RemoveHwBreakpoint(addr) => Ok(DebugResponse::RemoveHwBreakpoint( + self.vm + .remove_hw_breakpoint(addr) + .inspect_err(|e| tracing::error!("Failed to remove hw breakpoint: {:?}", e)) + .is_ok(), + )), + DebugMsg::RemoveSwBreakpoint(addr) => Ok(DebugResponse::RemoveSwBreakpoint( + self.remove_sw_breakpoint(addr, mem_access) + .inspect_err(|e| tracing::error!("Failed to remove sw breakpoint: {:?}", e)) + .is_ok(), + )), + DebugMsg::Step => { + #[cfg(target_arch = "aarch64")] + self.begin_software_step(true, mem_access)?; + #[cfg(target_arch = "x86_64")] + self.vm.set_single_step(true).inspect_err(|e| { + tracing::error!("Failed to enable step instruction: {:?}", e) + })?; + Ok(DebugResponse::Step) + } + DebugMsg::WriteAddr(addr, data) => { + self.write_addrs(addr, &data, mem_access) + .inspect_err(|e| tracing::error!("Failed to write to address: {:?}", e))?; + Ok(DebugResponse::WriteAddr) + } + DebugMsg::WriteRegisters(boxed_regs) => { + let (regs, fpu) = boxed_regs.as_ref(); + self.vm.set_regs(regs).map_err(VmError::Register)?; + self.vm.set_fpu(fpu).map_err(VmError::Register)?; + Ok(DebugResponse::WriteRegisters) + } + } + } + + pub(crate) fn recv_dbg_msg(&mut self) -> std::result::Result { + let gdb_conn = self + .gdb_conn + .as_mut() + .ok_or(RecvDbgMsgError::DebugNotEnabled)?; + Ok(gdb_conn.recv()?) + } + + pub(crate) fn send_dbg_msg( + &mut self, + cmd: DebugResponse, + ) -> std::result::Result<(), SendDbgMsgError> { + tracing::debug!("Sending {:?}", cmd); + let gdb_conn = self + .gdb_conn + .as_mut() + .ok_or(SendDbgMsgError::DebugNotEnabled)?; + Ok(gdb_conn.send(cmd)?) + } + + fn read_addrs( + &mut self, + mut gva: u64, + mut data: &mut [u8], + mem_access: &DebugMemoryView<'_>, + ) -> std::result::Result<(), ProcessDebugRequestError> { + tracing::debug!("Read addr: {:X} len: {:X}", gva, data.len()); + + while !data.is_empty() { + let gpa = self.vm.translate_gva(gva, mem_access)?; + let read_len = std::cmp::min(data.len(), PAGE_SIZE - (gva as usize & (PAGE_SIZE - 1))); + mem_access.read(&mut data[..read_len], gpa)?; + data = &mut data[read_len..]; + gva += read_len as u64; + } + + Ok(()) + } + + fn write_addrs( + &mut self, + mut gva: u64, + mut data: &[u8], + mem_access: &DebugMemoryView<'_>, + ) -> std::result::Result<(), ProcessDebugRequestError> { + tracing::debug!("Write addr: {:X} len: {:X}", gva, data.len()); + + let mut written = false; + let result = (|| { + while !data.is_empty() { + let gpa = self.vm.translate_gva(gva, mem_access)?; + let write_len = + std::cmp::min(data.len(), PAGE_SIZE - (gva as usize & (PAGE_SIZE - 1))); + mem_access + .write(&data[..write_len], gpa) + .map_err(ProcessDebugRequestError::WriteMemory)?; + written = true; + mem_access + .flush_host_instruction_cache(gpa, write_len) + .map_err(ProcessDebugRequestError::WriteMemory)?; + data = &data[write_len..]; + gva += write_len as u64; + } + Ok(()) + })(); + if written { + self.vm.sync_instruction_cache()?; + } + result + } + + fn add_sw_breakpoint( + &mut self, + gva: u64, + mem_access: &DebugMemoryView<'_>, + ) -> std::result::Result<(), ProcessDebugRequestError> { + if !valid_sw_breakpoint_address(gva) { + return Err(ProcessDebugRequestError::UnalignedSwBreakpoint { + address: gva, + alignment: SW_BP_SIZE, + }); + } + if self.sw_breakpoints.contains(gva) { + return Ok(()); + } + + let mut saved_instruction = vec![0; SW_BP_SIZE]; + self.read_addrs(gva, &mut saved_instruction, mem_access)?; + if let Err(error) = self.write_addrs(gva, &SW_BP, mem_access) { + if let Err(rollback_error) = self.write_addrs(gva, &saved_instruction, mem_access) { + tracing::error!( + "Failed to roll back software breakpoint {gva:#x}: {rollback_error}" + ); + } + return Err(error); + } + self.sw_breakpoints.insert(gva, saved_instruction); + Ok(()) + } + + fn remove_sw_breakpoint( + &mut self, + gva: u64, + mem_access: &DebugMemoryView<'_>, + ) -> std::result::Result<(), ProcessDebugRequestError> { + let saved_instruction = self + .sw_breakpoints + .get(gva) + .ok_or(ProcessDebugRequestError::SwBreakpointNotFound(gva))? + .to_vec(); + if let Err(error) = self.write_addrs(gva, &saved_instruction, mem_access) { + if let Err(rollback_error) = self.write_addrs(gva, &SW_BP, mem_access) { + tracing::error!( + "Failed to roll back software breakpoint removal at {gva:#x}: {rollback_error}" + ); + } + return Err(error); + } + self.sw_breakpoints.remove(gva); + Ok(()) + } + + #[cfg(target_arch = "aarch64")] + fn begin_software_step( + &mut self, + report_stop: bool, + mem_access: &DebugMemoryView<'_>, + ) -> std::result::Result<(), ProcessDebugRequestError> { + if self.pending_software_step.is_some() { + self.cancel_software_step(mem_access)?; + } + + let regs = self.vm.regs().map_err(VmError::Register)?; + let pc = regs.pc; + let Some(saved_instruction) = self.sw_breakpoints.get(pc).map(<[u8]>::to_vec) else { + self.vm.set_single_step(true)?; + return Ok(()); + }; + let result = self + .write_addrs(pc, &saved_instruction, mem_access) + .and_then(|()| self.vm.set_single_step(true).map_err(Into::into)); + if let Err(error) = result { + if let Err(rollback_error) = self.write_addrs(pc, &SW_BP, mem_access) { + tracing::error!( + "Failed to roll back breakpoint-step origin {pc:#x}: {rollback_error}" + ); + } + return Err(error); + } + + self.pending_software_step = Some(PendingSoftwareStep { + origin: pc, + report_stop, + }); + Ok(()) + } + + #[cfg(target_arch = "aarch64")] + fn cancel_software_step( + &mut self, + mem_access: &DebugMemoryView<'_>, + ) -> std::result::Result<(), ProcessDebugRequestError> { + self.vm.set_single_step(false)?; + let Some(pending) = self.pending_software_step.clone() else { + return Ok(()); + }; + self.restore_software_step(&pending, mem_access)?; + self.pending_software_step = None; + Ok(()) + } + + #[cfg(target_arch = "aarch64")] + fn restore_software_step( + &mut self, + pending: &PendingSoftwareStep, + mem_access: &DebugMemoryView<'_>, + ) -> std::result::Result<(), ProcessDebugRequestError> { + self.write_addrs(pending.origin, &SW_BP, mem_access)?; + Ok(()) + } + + #[cfg(target_arch = "aarch64")] + pub(super) fn cancel_pending_software_step( + &mut self, + mem_mgr: &crate::mem::mgr::SandboxMemoryManager, + ) -> std::result::Result<(), ProcessDebugRequestError> { + if self.gdb_conn.is_none() { + return Ok(()); + } + let mapped_regions = self.get_mapped_regions().cloned().collect(); + let mem_access = DebugMemoryView::new(mem_mgr, mapped_regions); + self.cancel_software_step(&mem_access) + } + + #[cfg(target_arch = "aarch64")] + pub(super) fn finish_software_step( + &mut self, + mem_mgr: &crate::mem::mgr::SandboxMemoryManager, + stop_reason: VcpuStopReason, + ) -> std::result::Result, ProcessDebugRequestError> { + let pending = self + .pending_software_step + .clone() + .expect("software-step completion requires pending state"); + let mapped_regions = self.get_mapped_regions().cloned().collect(); + let mem_access = DebugMemoryView::new(mem_mgr, mapped_regions); + self.restore_software_step(&pending, &mem_access)?; + self.vm.set_single_step(false)?; + self.pending_software_step = None; + + if pending.report_stop || !matches!(stop_reason, VcpuStopReason::DoneStep) { + Ok(Some(stop_reason)) + } else { + Ok(None) + } + } +} + +#[cfg(test)] +mod tests { + use super::SoftwareBreakpoints; + + #[test] + fn software_breakpoint_storage_preserves_full_instruction() { + let mut breakpoints = SoftwareBreakpoints::default(); + breakpoints.insert(0x4000, vec![0x11, 0x22, 0x33, 0x44]); + + assert_eq!( + breakpoints.get(0x4000), + Some([0x11, 0x22, 0x33, 0x44].as_slice()) + ); + } + + #[cfg(all(any(kvm, mshv3), target_arch = "aarch64"))] + mod memory_access_tests { + use hyperlight_common::vmem::PAGE_SIZE; + + use super::super::*; + use crate::hypervisor::gdb::DebugCommChannel; + use crate::mem::layout::SandboxMemoryLayout; + use crate::mem::mgr::SandboxMemoryManager; + use crate::mem::shared_mem::{ExclusiveSharedMemory, HostSharedMemory}; + use crate::sandbox::SandboxConfiguration; + use crate::sandbox::snapshot::{NextAction, SnapshotMemoryBacking}; + + fn noncontiguous_pages() -> (HyperlightVm, SandboxMemoryManager, u64) { + let mut config = SandboxConfiguration::default(); + config.set_scratch_size(config.get_scratch_size().next_multiple_of(page_size::get())); + let layout = SandboxMemoryLayout::new(config, PAGE_SIZE, 0, None).unwrap(); + let snapshot = crate::sandbox::snapshot::memory::tests::flat_memory( + &vec![0; page_size::get()], + &[0; PAGE_SIZE], + SandboxMemoryLayout::BASE_ADDRESS.next_multiple_of(page_size::get()) as u64, + hyperlight_common::layout::scratch_base_gpa(layout.get_scratch_size()), + ); + let memory = SandboxMemoryManager::new( + layout, + SnapshotMemoryBacking::from_snapshot(std::sync::Arc::new(snapshot)).unwrap(), + ExclusiveSharedMemory::new(layout.get_scratch_size()).unwrap(), + NextAction::None, + ); + let (memory, guest_memory) = memory.build().unwrap(); + let (_debugger, connection) = DebugCommChannel::unbounded(); + let mut vm = HyperlightVm::new( + guest_memory.shared_mem, + guest_memory.scratch_mem, + guest_memory.layout.get_pt_base_gpa(), + guest_memory.next_action, + 0, + page_size::get(), + &config, + Some(connection), + #[cfg(feature = "mem_profile")] + crate::sandbox::trace::MemTraceInfo::new(crate::mem::exe::LoadInfo::dummy().info) + .unwrap(), + ) + .unwrap(); + let root = memory.first_free_scratch_gpa(); + let access = DebugMemoryView::new(&memory, Vec::new()); + let mut tables = [0; 4 * PAGE_SIZE]; + for level in 0..3 { + let entry = (root + (level as u64 + 1) * PAGE_SIZE as u64) | 3; + tables[level * PAGE_SIZE..level * PAGE_SIZE + 8] + .copy_from_slice(&entry.to_le_bytes()); + } + for page in 0..3 { + let entry = (root + (4 + 2 * page) as u64 * PAGE_SIZE as u64) | 3; + let offset = 3 * PAGE_SIZE + (page + 1) * 8; + tables[offset..offset + 8].copy_from_slice(&entry.to_le_bytes()); + access + .write(&vec![page as u8 + 1; PAGE_SIZE], entry & !0xfff) + .unwrap(); + } + for page in [5, 7] { + access + .write(&[0xee; PAGE_SIZE], root + page * PAGE_SIZE as u64) + .unwrap(); + } + access.write(&tables, root).unwrap(); + let registers = vm.get_snapshot_sregs().unwrap(); + vm.apply_sregs(root, ®isters).unwrap(); + (vm, memory, root) + } + + #[test] + fn reads_translate_each_noncontiguous_guest_page() { + let (mut vm, memory, _) = noncontiguous_pages(); + let access = DebugMemoryView::new(&memory, Vec::new()); + let address = (2 * PAGE_SIZE - 3) as u64; + let DebugResponse::ReadAddr(data) = vm + .process_dbg_request(DebugMsg::ReadAddr(address, PAGE_SIZE + 8), &access) + .unwrap() + else { + panic!("missing memory read response"); + }; + assert_eq!(&data[..3], &[1; 3]); + assert!(data[3..3 + PAGE_SIZE].iter().all(|&byte| byte == 2)); + assert_eq!(&data[3 + PAGE_SIZE..], &[3; 5]); + } + + #[test] + fn writes_preserve_gaps_between_noncontiguous_guest_pages() { + let (mut vm, memory, root) = noncontiguous_pages(); + let access = DebugMemoryView::new(&memory, Vec::new()); + let address = (2 * PAGE_SIZE - 3) as u64; + assert!(matches!( + vm.process_dbg_request( + DebugMsg::WriteAddr(address, vec![0xab; PAGE_SIZE + 8]), + &access, + ) + .unwrap(), + DebugResponse::WriteAddr + )); + let mut data = [0; PAGE_SIZE]; + for page in [5, 7] { + access + .read(&mut data, root + page * PAGE_SIZE as u64) + .unwrap(); + assert!(data.iter().all(|&byte| byte == 0xee)); + } + for (page, prefix, suffix) in [(4, PAGE_SIZE - 3, 0), (6, 0, 0), (8, 0, PAGE_SIZE - 5)] + { + access + .read(&mut data, root + page * PAGE_SIZE as u64) + .unwrap(); + let original = ((page - 4) / 2 + 1) as u8; + assert!(data[..prefix].iter().all(|&byte| byte == original)); + assert!( + data[prefix..PAGE_SIZE - suffix] + .iter() + .all(|&byte| byte == 0xab) + ); + assert!( + data[PAGE_SIZE - suffix..] + .iter() + .all(|&byte| byte == original) + ); + } + } + + #[test] + fn resume_translation_errors_keep_the_debug_session_stopped() { + let (mut vm, memory, _) = noncontiguous_pages(); + let (debugger, connection) = DebugCommChannel::unbounded(); + vm.gdb_conn = Some(connection); + let mut regs = vm.vm.regs().unwrap(); + regs.pc = 4 * PAGE_SIZE as u64; + vm.vm.set_regs(®s).unwrap(); + vm.sw_breakpoints.insert(regs.pc, vec![0; 4]); + debugger.send(DebugMsg::Continue).unwrap(); + debugger.send(DebugMsg::Step).unwrap(); + regs.pc = PAGE_SIZE as u64; + debugger + .send(DebugMsg::WriteRegisters(Box::new(( + regs, + crate::hypervisor::regs::CommonFpu::default(), + )))) + .unwrap(); + debugger.send(DebugMsg::Continue).unwrap(); + + vm.handle_debug(&memory, crate::hypervisor::gdb::VcpuStopReason::Initial) + .unwrap(); + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::VcpuStopped(_) + )); + for _ in 0..2 { + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::NotAllowed + )); + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::VcpuStopped(_) + )); + } + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::WriteRegisters + )); + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::Continue + )); + } + } +} diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs index b01b44200..a34aca9a4 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/mod.rs @@ -7,28 +7,29 @@ mod x86_64; #[cfg(target_arch = "aarch64")] mod aarch64; +#[cfg(gdb)] +mod debug; + #[cfg(all(test, not(gdb)))] pub(crate) mod test_support; -#[cfg(gdb)] -use std::collections::HashMap; use std::str::FromStr; use std::sync::{Arc, Mutex}; use hyperlight_common::log_level::GuestLogFilter; use tracing_core::LevelFilter; +#[cfg(gdb)] +use self::debug::{ProcessDebugRequestError, SoftwareBreakpoints}; use crate::HyperlightError; #[cfg(gdb)] use crate::hypervisor::gdb::DebuggableVm; -#[cfg(gdb)] +#[cfg(all(gdb, target_arch = "x86_64"))] use crate::hypervisor::gdb::arch::VcpuStopReasonError; #[cfg(gdb)] use crate::hypervisor::gdb::{ DebugCommChannel, DebugError, DebugMsg, DebugResponse, GdbTargetError, VcpuStopReason, }; -#[cfg(gdb)] -use crate::hypervisor::hyperlight_vm::x86_64::debug::ProcessDebugRequestError; #[cfg(not(gdb))] use crate::hypervisor::virtual_machine::VirtualMachine; use crate::hypervisor::virtual_machine::{ @@ -206,7 +207,7 @@ pub enum RunVmError { RunVcpu(#[from] RunVcpuError), #[error("Unexpected VM exit: {0}")] UnexpectedVmExit(String), - #[cfg(gdb)] + #[cfg(all(gdb, target_arch = "x86_64"))] #[error("vCPU stop reason error: {0}")] VcpuStopReason(#[from] VcpuStopReasonError), } @@ -257,6 +258,9 @@ pub enum UpdateRegionError { MapMemory(#[from] MapMemoryError), #[error("VM unmap memory error: {0}")] UnmapMemory(#[from] UnmapMemoryError), + #[cfg(all(gdb, target_arch = "aarch64"))] + #[error("Cannot replace snapshot memory while software breakpoints are installed")] + ActiveSoftwareBreakpoints, } /// Errors that can occur when accessing the root page table state @@ -387,14 +391,18 @@ pub(crate) struct HyperlightVm { #[cfg(gdb)] pub(super) gdb_conn: Option>, #[cfg(gdb)] - pub(super) sw_breakpoints: HashMap, // addr -> original instruction + pub(super) sw_breakpoints: SoftwareBreakpoints, + #[cfg(all(gdb, target_arch = "aarch64"))] + pending_software_step: Option, /// One-shot hw breakpoint installed at the entry address when gdb is /// enabled, so the gdb stub gets a `VcpuStopped` to enter its event /// loop on the first vCPU run after construction. Cleared by the /// `VmExit::Debug` arm of `run` the first time a `HwBp` stop fires /// at the entry address. - #[cfg(gdb)] + #[cfg(all(gdb, target_arch = "x86_64"))] pub(super) one_shot_entry_bp: Option, + #[cfg(all(gdb, target_arch = "aarch64"))] + pub(super) initial_debug_stop_pending: bool, #[cfg(feature = "mem_profile")] pub(super) trace_info: MemTraceInfo, #[cfg(crashdump)] @@ -524,6 +532,11 @@ impl HyperlightVm { &mut self, snapshot: SnapshotMemoryBacking, ) -> Result<(), UpdateRegionError> { + #[cfg(all(gdb, target_arch = "aarch64"))] + if self.has_sw_breakpoints() { + return Err(UpdateRegionError::ActiveSoftwareBreakpoints); + } + let target_mappings = snapshot.mappings(); for index in (0..self.snapshot_mappings.len()).rev() { @@ -698,7 +711,15 @@ impl HyperlightVm { // - Signals will not be sent match exit_reason { #[cfg(gdb)] - Ok(VmExit::Debug { dr6, exception }) => { + Ok(VmExit::Debug { + #[cfg(target_arch = "x86_64")] + dr6, + #[cfg(target_arch = "x86_64")] + exception, + #[cfg(target_arch = "aarch64")] + reason: stop_reason, + }) => { + #[cfg(target_arch = "x86_64")] // Classify the debug exit. `vcpu_stop_reason` is a // pure classifier and has no side effects on the VM. let stop_reason = crate::hypervisor::gdb::arch::vcpu_stop_reason( @@ -706,6 +727,7 @@ impl HyperlightVm { dr6, exception, )?; + #[cfg(target_arch = "x86_64")] // Remove the one-shot entry breakpoint installed by // `HyperlightVm::new` the first time it fires so it // does not interfere with later user-installed @@ -721,12 +743,25 @@ impl HyperlightVm { self.one_shot_entry_bp = None; } } + #[cfg(target_arch = "aarch64")] + let stop_reason = if self.pending_software_step.is_some() { + match self.finish_software_step(mem_mgr, stop_reason) { + Ok(Some(reason)) => reason, + Ok(None) => continue, + Err(e) => break Err(HandleDebugError::from(e).into()), + } + } else { + stop_reason + }; if let Err(e) = self.handle_debug(mem_mgr, stop_reason) { break Err(e.into()); } } Ok(VmExit::Halt()) => { + #[cfg(all(gdb, target_arch = "aarch64"))] + self.cancel_pending_software_step(mem_mgr) + .map_err(HandleDebugError::from)?; break Ok(()); } Ok(VmExit::IoOut(port, data)) => { @@ -782,13 +817,21 @@ impl HyperlightVm { continue; } + #[cfg(all(gdb, target_arch = "aarch64"))] + if let Err(e) = self.cancel_pending_software_step(mem_mgr) { + break Err(HandleDebugError::from(e).into()); + } + // If the vcpu was interrupted by a debugger, we need to handle it #[cfg(gdb)] - { + if debug_interrupted { self.interrupt_handle.state().clear_debug_interrupt(); if let Err(e) = self.handle_debug(mem_mgr, VcpuStopReason::Interrupt) { break Err(e.into()); } + if !cancel_requested { + continue; + } } metrics::counter!(METRIC_GUEST_CANCELLATION).increment(1); diff --git a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs index f6037240a..1e27df6b0 100644 --- a/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/hyperlight_vm/x86_64.rs @@ -1,8 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 The Hyperlight Authors. -#[cfg(gdb)] -use std::collections::HashMap; use std::sync::{Arc, Mutex}; use tracing::{Span, instrument}; @@ -17,11 +15,7 @@ use crate::hypervisor::WindowsInterruptHandle; #[cfg(crashdump)] use crate::hypervisor::crashdump; #[cfg(gdb)] -use crate::hypervisor::gdb::{ - DebugCommChannel, DebugMsg, DebugResponse, DebuggableVm, VcpuStopReason, -}; -#[cfg(gdb)] -use crate::hypervisor::gdb::{DebugError, DebugMemoryAccessError}; +use crate::hypervisor::gdb::{DebugCommChannel, DebugMsg, DebugResponse, DebuggableVm}; use crate::hypervisor::regs::{ CommonDebugRegs, CommonFpu, CommonRegisters, CommonSpecialRegisters, MsrEntry, MsrResetState, }; @@ -126,7 +120,7 @@ impl HyperlightVm { #[cfg(gdb)] gdb_conn, #[cfg(gdb)] - sw_breakpoints: HashMap::new(), + sw_breakpoints: SoftwareBreakpoints::default(), #[cfg(gdb)] one_shot_entry_bp: None, #[cfg(feature = "mem_profile")] @@ -372,145 +366,6 @@ impl HyperlightVm { Ok(()) } - // Handle a debug exit - #[cfg(gdb)] - pub(super) fn handle_debug( - &mut self, - mem_mgr: &SandboxMemoryManager, - stop_reason: VcpuStopReason, - ) -> std::result::Result<(), HandleDebugError> { - use debug::ProcessDebugRequestError; - - use crate::hypervisor::gdb::DebugMemoryView; - - if self.gdb_conn.is_none() { - return Err(HandleDebugError::DebugNotEnabled); - } - - let mem_access = - DebugMemoryView::new(mem_mgr, self.get_mapped_regions().cloned().collect()); - - match stop_reason { - // If the vCPU stopped because of a crash, we need to handle it differently - // We do not want to allow resuming execution or placing breakpoints - // because the guest has crashed. - // We only allow reading registers and memory - VcpuStopReason::Crash => { - self.send_dbg_msg(DebugResponse::VcpuStopped(stop_reason))?; - - loop { - tracing::debug!("Debug wait for event to resume vCPU"); - // Wait for a message from gdb - let req = self.recv_dbg_msg()?; - - // Flag to store if we should deny continue or step requests - let mut deny_continue = false; - // Flag to store if we should detach from the gdb session - let mut detach = false; - - let response = match req { - // Allow the detach request to disable debugging by continuing resuming - // hypervisor crash error reporting - DebugMsg::DisableDebug => { - detach = true; - DebugResponse::DisableDebug - } - // Do not allow continue or step requests - DebugMsg::Continue | DebugMsg::Step => { - deny_continue = true; - DebugResponse::NotAllowed - } - // Do not allow adding/removing breakpoints and writing to memory or registers - DebugMsg::AddHwBreakpoint(_) - | DebugMsg::AddSwBreakpoint(_) - | DebugMsg::RemoveHwBreakpoint(_) - | DebugMsg::RemoveSwBreakpoint(_) - | DebugMsg::WriteAddr(_, _) - | DebugMsg::WriteRegisters(_) => DebugResponse::NotAllowed, - - // For all other requests, we will process them normally - _ => { - let result = self.process_dbg_request(req, &mem_access); - match result { - Ok(response) => response, - // Treat non-fatal errors separately so the guest doesn't fail - Err(ProcessDebugRequestError::ReadMemory( - DebugMemoryAccessError::TranslateGuestAddress(_), - )) - | Err(ProcessDebugRequestError::Debug(DebugError::TranslateGva( - _, - ))) => DebugResponse::ErrorOccurred, - Err(e) => { - tracing::error!("Error processing debug request: {:?}", e); - return Err(HandleDebugError::ProcessRequest(e)); - } - } - } - }; - - // Send the response to the request back to gdb - self.send_dbg_msg(response)?; - - // If we are denying continue or step requests, the debugger assumes the - // execution started so we need to report a stop reason as a crash and let - // it request to read registers/memory to figure out what happened - if deny_continue { - self.send_dbg_msg(DebugResponse::VcpuStopped(VcpuStopReason::Crash))?; - } - - // If we are detaching, we will break the loop and the Hypervisor will continue - // to handle the Crash reason - if detach { - break; - } - } - } - // If the vCPU stopped because of any other reason except a crash, we can handle it - // normally - _ => { - // Send the stop reason to the gdb thread - self.send_dbg_msg(DebugResponse::VcpuStopped(stop_reason))?; - - loop { - tracing::debug!("Debug wait for event to resume vCPU"); - // Wait for a message from gdb - let req = self.recv_dbg_msg()?; - - let result = self.process_dbg_request(req, &mem_access); - - let response = match result { - Ok(response) => response, - // Treat non-fatal errors separately so the guest doesn't fail - Err(ProcessDebugRequestError::ReadMemory( - DebugMemoryAccessError::TranslateGuestAddress(_), - )) - | Err(ProcessDebugRequestError::Debug(DebugError::TranslateGva(_))) => { - DebugResponse::ErrorOccurred - } - Err(e) => { - return Err(HandleDebugError::ProcessRequest(e)); - } - }; - - let cont = matches!( - response, - DebugResponse::Continue | DebugResponse::Step | DebugResponse::DisableDebug - ); - - self.send_dbg_msg(response)?; - - // Check if we should continue execution - // We continue if the response is one of the following: Step, Continue, or DisableDebug - if cont { - break; - } - } - } - } - - Ok(()) - } - #[cfg(crashdump)] pub(crate) fn crashdump_context( &self, @@ -586,270 +441,6 @@ impl HyperlightVm { } } -#[cfg(gdb)] -pub(super) mod debug { - use super::HyperlightVm; - use crate::hypervisor::gdb::arch::{SW_BP, SW_BP_SIZE}; - use crate::hypervisor::gdb::{ - DebugError, DebugMemoryAccessError, DebugMemoryView, DebugMsg, DebugResponse, - }; - use crate::hypervisor::virtual_machine::VmError; - - /// Errors that can occur during GDB debug request processing - #[derive(Debug, thiserror::Error)] - pub enum ProcessDebugRequestError { - #[error("Debug is not enabled")] - DebugNotEnabled, - #[error("VM operation error: {0}")] - Vm(#[from] VmError), - #[error("Debug operation error: {0}")] - Debug(#[from] DebugError), - #[error("Address {0:#x} is not a software breakpoint")] - SwBreakpointNotFound(u64), - #[error("Failed to read memory: {0}")] - ReadMemory(#[from] DebugMemoryAccessError), - #[error("Failed to write memory: {0}")] - WriteMemory(DebugMemoryAccessError), - } - - impl HyperlightVm { - pub(crate) fn process_dbg_request( - &mut self, - req: DebugMsg, - mem_access: &DebugMemoryView<'_>, - ) -> std::result::Result { - if self.gdb_conn.is_some() { - match req { - DebugMsg::AddHwBreakpoint(addr) => Ok(DebugResponse::AddHwBreakpoint( - self.vm - .add_hw_breakpoint(addr) - .map_err(|e| { - tracing::error!("Failed to add hw breakpoint: {:?}", e); - - e - }) - .is_ok(), - )), - DebugMsg::AddSwBreakpoint(addr) => Ok(DebugResponse::AddSwBreakpoint( - self.add_sw_breakpoint(addr, mem_access) - .map_err(|e| { - tracing::error!("Failed to add sw breakpoint: {:?}", e); - - e - }) - .is_ok(), - )), - DebugMsg::Continue => { - self.vm.set_single_step(false).map_err(|e| { - tracing::error!("Failed to continue execution: {:?}", e); - - e - })?; - - Ok(DebugResponse::Continue) - } - DebugMsg::DisableDebug => { - self.vm.set_debug(false).map_err(|e| { - tracing::error!("Failed to disable debugging: {:?}", e); - e - })?; - - Ok(DebugResponse::DisableDebug) - } - DebugMsg::GetCodeSectionOffset => Ok(DebugResponse::GetCodeSectionOffset( - mem_access.code_section_offset(), - )), - DebugMsg::ReadAddr(addr, len) => { - let mut data = vec![0u8; len]; - - self.read_addrs(addr, &mut data, mem_access).map_err(|e| { - tracing::error!("Failed to read from address: {:?}", e); - - e - })?; - - Ok(DebugResponse::ReadAddr(data)) - } - DebugMsg::ReadRegisters => { - let regs = self.vm.regs().map_err(VmError::Register)?; - let fpu = self.vm.fpu().map_err(VmError::Register)?; - Ok(DebugResponse::ReadRegisters(Box::new((regs, fpu)))) - } - DebugMsg::RemoveHwBreakpoint(addr) => Ok(DebugResponse::RemoveHwBreakpoint( - self.vm - .remove_hw_breakpoint(addr) - .map_err(|e| { - tracing::error!("Failed to remove hw breakpoint: {:?}", e); - - e - }) - .is_ok(), - )), - DebugMsg::RemoveSwBreakpoint(addr) => Ok(DebugResponse::RemoveSwBreakpoint( - self.remove_sw_breakpoint(addr, mem_access) - .map_err(|e| { - tracing::error!("Failed to remove sw breakpoint: {:?}", e); - - e - }) - .is_ok(), - )), - DebugMsg::Step => { - self.vm.set_single_step(true).map_err(|e| { - tracing::error!("Failed to enable step instruction: {:?}", e); - - e - })?; - - Ok(DebugResponse::Step) - } - DebugMsg::WriteAddr(addr, data) => { - self.write_addrs(addr, &data, mem_access).map_err(|e| { - tracing::error!("Failed to write to address: {:?}", e); - - e - })?; - - Ok(DebugResponse::WriteAddr) - } - DebugMsg::WriteRegisters(boxed_regs) => { - let (regs, fpu) = boxed_regs.as_ref(); - self.vm.set_regs(regs).map_err(VmError::Register)?; - self.vm.set_fpu(fpu).map_err(VmError::Register)?; - - Ok(DebugResponse::WriteRegisters) - } - } - } else { - Err(ProcessDebugRequestError::DebugNotEnabled) - } - } - - pub(crate) fn recv_dbg_msg( - &mut self, - ) -> std::result::Result { - use super::RecvDbgMsgError; - - let gdb_conn = self - .gdb_conn - .as_mut() - .ok_or(RecvDbgMsgError::DebugNotEnabled)?; - - Ok(gdb_conn.recv()?) - } - - pub(crate) fn send_dbg_msg( - &mut self, - cmd: DebugResponse, - ) -> std::result::Result<(), super::SendDbgMsgError> { - use super::SendDbgMsgError; - - tracing::debug!("Sending {:?}", cmd); - - let gdb_conn = self - .gdb_conn - .as_mut() - .ok_or(SendDbgMsgError::DebugNotEnabled)?; - - Ok(gdb_conn.send(cmd)?) - } - - fn read_addrs( - &mut self, - mut gva: u64, - mut data: &mut [u8], - mem_access: &DebugMemoryView<'_>, - ) -> std::result::Result<(), ProcessDebugRequestError> { - let data_len = data.len(); - tracing::debug!("Read addr: {:X} len: {:X}", gva, data_len); - - while !data.is_empty() { - let gpa = self.vm.translate_gva(gva)?; - - let read_len = std::cmp::min( - data.len(), - page_size::get() - (gpa as usize & (page_size::get() - 1)), - ); - - mem_access.read(&mut data[..read_len], gpa)?; - - data = &mut data[read_len..]; - gva += read_len as u64; - } - - Ok(()) - } - - /// Copies the data from the provided slice to the guest memory address - /// The address is checked to be a valid guest address - fn write_addrs( - &mut self, - mut gva: u64, - mut data: &[u8], - mem_access: &DebugMemoryView<'_>, - ) -> std::result::Result<(), ProcessDebugRequestError> { - let data_len = data.len(); - tracing::debug!("Write addr: {:X} len: {:X}", gva, data_len); - - while !data.is_empty() { - let gpa = self.vm.translate_gva(gva)?; - - let write_len = std::cmp::min( - data.len(), - page_size::get() - (gpa as usize & (page_size::get() - 1)), - ); - - // Use the memory access to write to guest memory - mem_access - .write(&data[..write_len], gpa) - .map_err(ProcessDebugRequestError::WriteMemory)?; - - data = &data[write_len..]; - gva += write_len as u64; - } - - Ok(()) - } - - // Must be idempotent! - fn add_sw_breakpoint( - &mut self, - gva: u64, - mem_access: &DebugMemoryView<'_>, - ) -> std::result::Result<(), ProcessDebugRequestError> { - // Check if breakpoint already exists - if self.sw_breakpoints.contains_key(&gva) { - return Ok(()); - } - - // Write breakpoint OP code to write to guest memory - let mut save_data = [0; SW_BP_SIZE]; - self.read_addrs(gva, &mut save_data[..], mem_access)?; - self.write_addrs(gva, &SW_BP, mem_access)?; - - // Save guest memory to restore when breakpoint is removed - self.sw_breakpoints.insert(gva, save_data[0]); - - Ok(()) - } - - fn remove_sw_breakpoint( - &mut self, - gva: u64, - mem_access: &DebugMemoryView<'_>, - ) -> std::result::Result<(), ProcessDebugRequestError> { - if let Some(saved_data) = self.sw_breakpoints.remove(&gva) { - // Restore saved data to the guest's memory - self.write_addrs(gva, &[saved_data], mem_access)?; - - Ok(()) - } else { - Err(ProcessDebugRequestError::SwBreakpointNotFound(gva)) - } - } - } -} - #[cfg(test)] #[allow(clippy::needless_range_loop)] mod tests { diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs index b691742a1..04202f8d2 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/x86_64.rs @@ -710,7 +710,11 @@ impl VirtualMachine for KvmVm { #[cfg(gdb)] impl DebuggableVm for KvmVm { - fn translate_gva(&self, gva: u64) -> std::result::Result { + fn translate_gva( + &self, + gva: u64, + _memory: &crate::hypervisor::gdb::DebugMemoryView<'_>, + ) -> std::result::Result { let gpa = self .vcpu_fd .translate_gva(gva) diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs index 8f2ebc566..0feba0570 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mod.rs @@ -8,6 +8,8 @@ use tracing::{Span, instrument}; #[cfg(gdb)] use crate::hypervisor::gdb::DebugError; +#[cfg(all(gdb, target_arch = "aarch64"))] +use crate::hypervisor::gdb::VcpuStopReason; use crate::hypervisor::regs::{ CommonDebugRegs, CommonFpu, CommonRegisters, CommonSpecialRegisters, }; @@ -140,6 +142,8 @@ pub(crate) enum VmExit { dr6: u64, #[cfg(target_arch = "x86_64")] exception: u32, + #[cfg(target_arch = "aarch64")] + reason: VcpuStopReason, }, /// The vCPU has halted Halt(), @@ -155,7 +159,10 @@ pub(crate) enum VmExit { Unknown(String), /// The operation should be retried, for example this can happen on Linux where a call to run the CPU can return EAGAIN #[cfg_attr( - any(target_os = "windows", feature = "hw-interrupts"), + all( + any(target_os = "windows", feature = "hw-interrupts"), + not(all(gdb, target_arch = "aarch64")) + ), expect( dead_code, reason = "Retry() is never constructed on Windows or with hw-interrupts (EAGAIN causes continue instead)" @@ -193,6 +200,9 @@ pub enum CreateVmError { HypervisorNotAvailable(HypervisorError), #[error("Initialize VM failed: {0}")] InitializeVm(HypervisorError), + #[cfg(all(gdb, target_arch = "aarch64"))] + #[error("Failed to initialize ARM64 debug support: {0}")] + InitializeDebug(String), #[cfg(all(kvm, target_arch = "x86_64"))] #[error("KVM MSR filtering requires KVM_CAP_X86_MSR_FILTER")] MsrFilterNotSupported, diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs index 1407fceaf..4ce9274b5 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/x86_64.rs @@ -792,7 +792,11 @@ impl VirtualMachine for MshvVm { #[cfg(gdb)] impl DebuggableVm for MshvVm { - fn translate_gva(&self, gva: u64) -> std::result::Result { + fn translate_gva( + &self, + gva: u64, + _memory: &crate::hypervisor::gdb::DebugMemoryView<'_>, + ) -> std::result::Result { use mshv_bindings::HV_TRANSLATE_GVA_VALIDATE_READ; // Do not use HV_TRANSLATE_GVA_VALIDATE_WRITE, since many diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/whp/x86_64.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/whp/x86_64.rs index 9c0d27247..84ec6c1fb 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/whp/x86_64.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/whp/x86_64.rs @@ -1136,7 +1136,11 @@ impl VirtualMachine for WhpVm { #[cfg(gdb)] impl DebuggableVm for WhpVm { - fn translate_gva(&self, gva: u64) -> std::result::Result { + fn translate_gva( + &self, + gva: u64, + _memory: &crate::hypervisor::gdb::DebugMemoryView<'_>, + ) -> std::result::Result { let mut gpa = 0; let mut result = WHV_TRANSLATE_GVA_RESULT::default(); diff --git a/src/hyperlight_host/src/mem/elf.rs b/src/hyperlight_host/src/mem/elf.rs index f8981883b..11cef5ddd 100644 --- a/src/hyperlight_host/src/mem/elf.rs +++ b/src/hyperlight_host/src/mem/elf.rs @@ -88,6 +88,8 @@ pub(crate) struct ElfInfo { /// The hyperlight version string embedded by `hyperlight-guest-bin`, if /// present. Used to detect version/ABI mismatches between guest and host. guest_bin_version: Option, + #[cfg(all(gdb, target_arch = "aarch64"))] + debug_vectors: Option, } #[cfg(feature = "mem_profile")] @@ -248,6 +250,11 @@ impl ElfInfo { // Look for the hyperlight version note embedded by // hyperlight-guest-bin. let guest_bin_version = Self::read_version_note(&elf, &bytes); + #[cfg(all(gdb, target_arch = "aarch64"))] + let debug_vectors = elf.syms.iter().find_map(|symbol| { + (elf.strtab.get_at(symbol.st_name) == Some("hyperlight_debug_vectors")) + .then_some(symbol.st_value) + }); let phdrs = std::mem::take(&mut elf.program_headers); let entry = elf.entry; @@ -279,6 +286,8 @@ impl ElfInfo { relocs, is_pie, guest_bin_version, + #[cfg(all(gdb, target_arch = "aarch64"))] + debug_vectors, }) } @@ -410,11 +419,23 @@ impl ElfInfo { } } } + #[cfg(all(gdb, target_arch = "aarch64"))] + let debug_vectors = self + .debug_vectors + .map(|address| { + address + .checked_sub(base_va) + .and_then(|offset| load_gva.checked_add(offset)) + .ok_or_else(|| new_error!("ARM64 debug vector address overflows")) + }) + .transpose()?; cfg_if::cfg_if! { if #[cfg(feature = "mem_profile")] { let va_size = self.get_va_size() as u64; let base_svma = self.get_base_va(); Ok(LoadInfo { + #[cfg(all(gdb, target_arch = "aarch64"))] + debug_vectors, info: Arc::new(UnwindInfo { payload: self.payload, load_addr: load_gva, @@ -424,7 +445,10 @@ impl ElfInfo { }) }) } else { - Ok(LoadInfo {}) + Ok(LoadInfo { + #[cfg(all(gdb, target_arch = "aarch64"))] + debug_vectors, + }) } } } diff --git a/src/hyperlight_host/src/mem/exe.rs b/src/hyperlight_host/src/mem/exe.rs index aedb85b1a..7a8746b72 100644 --- a/src/hyperlight_host/src/mem/exe.rs +++ b/src/hyperlight_host/src/mem/exe.rs @@ -48,6 +48,8 @@ impl framehop::ModuleSectionInfo for &DummyUnwindInfo { #[derive(Clone)] pub(crate) struct LoadInfo { + #[cfg(all(gdb, target_arch = "aarch64"))] + pub(crate) debug_vectors: Option, #[cfg(feature = "mem_profile")] pub(crate) info: Arc, } @@ -55,6 +57,8 @@ pub(crate) struct LoadInfo { impl LoadInfo { pub(crate) fn dummy() -> Self { LoadInfo { + #[cfg(all(gdb, target_arch = "aarch64"))] + debug_vectors: None, #[cfg(feature = "mem_profile")] info: Arc::new(DummyUnwindInfo {}), } diff --git a/src/hyperlight_host/src/mem/mgr.rs b/src/hyperlight_host/src/mem/mgr.rs index 80b2ee797..33e606577 100644 --- a/src/hyperlight_host/src/mem/mgr.rs +++ b/src/hyperlight_host/src/mem/mgr.rs @@ -232,8 +232,8 @@ impl<'a> GuestPhysicalMemoryView<'a> { Ok(()) } - #[cfg(crashdump)] - fn host_range(&self, gpa: u64, len: usize) -> Result> { + #[cfg(any(crashdump, all(gdb, target_arch = "aarch64")))] + pub(crate) fn host_range(&self, gpa: u64, len: usize) -> Result> { let backing = self .resolve(gpa, len) .ok_or_else(|| new_error!("GPA range is not backed: {gpa:#x}"))?; diff --git a/src/hyperlight_host/src/sandbox/initialized.rs b/src/hyperlight_host/src/sandbox/initialized.rs index 612a388fd..e76f91964 100644 --- a/src/hyperlight_host/src/sandbox/initialized.rs +++ b/src/hyperlight_host/src/sandbox/initialized.rs @@ -481,6 +481,13 @@ impl Sandbox { #[instrument(err(Debug), skip_all, parent = Span::current())] pub fn snapshot_with_options(&mut self, options: &SnapshotOptions) -> Result> { self.check_ready()?; + #[cfg(all(gdb, target_arch = "aarch64"))] + if self.vm.has_sw_breakpoints() { + return Err(crate::new_error!( + "Cannot snapshot an ARM64 sandbox while software breakpoints are installed" + )); + } + // Unpacked in full so that a new option has to decide whether the // cached plain snapshot still answers it. let SnapshotOptions { scratch_size } = options; @@ -504,6 +511,12 @@ impl Sandbox { fn capture_snapshot(&mut self, scratch_size: usize) -> Result> { if self.transport_dirty { self.checkpoint_transport_for_snapshot()?; + #[cfg(all(gdb, target_arch = "aarch64"))] + if self.vm.has_sw_breakpoints() { + return Err(crate::new_error!( + "Cannot snapshot an ARM64 sandbox while software breakpoints are installed" + )); + } } let mapped_regions_iter = self.vm.get_mapped_regions(); @@ -724,6 +737,13 @@ impl Sandbox { return Err(HyperlightError::UnrecoverableSandbox); } + #[cfg(all(gdb, target_arch = "aarch64"))] + if self.status.is_ready() && self.vm.has_sw_breakpoints() { + return Err(crate::new_error!( + "Cannot restore an ARM64 sandbox while software breakpoints are installed" + )); + } + // Currently, we do not try to optimise restore to the // most-current snapshot. This is because the most-current // snapshot, while it must have identical virtual memory @@ -772,6 +792,10 @@ impl Sandbox { } // Errors below leave the sandbox poisoned unless base mapping updates make it unrecoverable. + #[cfg(all(gdb, target_arch = "aarch64"))] + if self.status.is_poisoned() { + self.vm.discard_software_breakpoints(); + } self.status = SandboxStatus::Poisoned; self.snapshot = None; @@ -1498,6 +1522,230 @@ mod tests { SnapshotOptions::default().scratch_size(scratch_size) } + #[cfg(all(gdb, target_arch = "aarch64"))] + mod gdb_recovery_tests { + use super::*; + use crate::hypervisor::gdb::{ + DebugCommChannel, DebugMemoryView, DebugMsg, DebugResponse, VcpuStopReason, + }; + use crate::hypervisor::hyperlight_vm::HyperlightVm; + use crate::mem::ptr::RawPtr; + use crate::sandbox::snapshot::NextAction; + + fn sandbox_with_debugger() -> (Sandbox, DebugCommChannel, u64) { + let guest = + UninitializedSandbox::new(GuestBinary::FilePath(simple_guest_as_pathbuf()), None) + .unwrap(); + let vectors = guest.load_info.debug_vectors; + let (mut host, memory) = guest.mgr.build().unwrap(); + let (debugger, connection) = DebugCommChannel::unbounded(); + debugger.send(DebugMsg::Continue).unwrap(); + let mut vm = HyperlightVm::new( + memory.shared_mem, + memory.scratch_mem, + memory.layout.get_pt_base_gpa(), + memory.next_action, + guest.stack_top_gva, + page_size::get(), + &guest.config, + Some(connection), + #[cfg(feature = "mem_profile")] + crate::sandbox::trace::MemTraceInfo::new(guest.load_info.info).unwrap(), + ) + .unwrap(); + vm.initialize_debug_vectors(vectors).unwrap(); + vm.initialise( + RawPtr::from(host.layout.peb_address() as u64), + 0, + &mut host, + &guest.host_funcs, + None, + ) + .unwrap(); + host.finish_snapshot_checkpoint().unwrap(); + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::InterruptHandle(_) + )); + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::VcpuStopped(VcpuStopReason::Initial) + )); + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::Continue + )); + + let bytes = std::fs::read(simple_guest_as_pathbuf()).unwrap(); + let elf = goblin::elf::Elf::parse(&bytes).unwrap(); + let entry = elf + .syms + .iter() + .find(|symbol| elf.strtab.get_at(symbol.st_name) == Some("hyperlight_main")) + .unwrap() + .st_value + + host.layout.get_guest_code_gva() as u64; + ( + Sandbox::from_uninit(guest.host_funcs, host, vm, guest.config), + debugger, + entry, + ) + } + + fn request(sandbox: &mut Sandbox, message: DebugMsg) -> DebugResponse { + let memory = DebugMemoryView::new( + &sandbox.mem_mgr, + sandbox.vm.get_mapped_regions().cloned().collect(), + ); + sandbox.vm.process_dbg_request(message, &memory).unwrap() + } + + #[cfg(all(target_os = "linux", any(kvm, mshv3)))] + #[test] + fn debugger_rejects_the_unbacked_io_page() { + let (mut sandbox, _debugger, _) = sandbox_with_debugger(); + let address = hyperlight_common::layout::io_page().unwrap().1; + let memory = DebugMemoryView::new(&sandbox.mem_mgr, Vec::new()); + let root = sandbox.vm.get_root_pt().unwrap(); + assert!(matches!( + memory.translate_gva(address, root), + Err(crate::hypervisor::gdb::DebugError::TranslateGva(gva)) if gva == address + )); + sandbox.call::<()>("NoOp", ()).unwrap(); + } + + #[test] + fn snapshot_rejects_breakpoints_added_during_checkpoint_without_poisoning() { + let (mut sandbox, debugger, breakpoint) = sandbox_with_debugger(); + let snapshot = sandbox.snapshot().unwrap(); + sandbox.call::<()>("NoOp", ()).unwrap(); + assert!(sandbox.transport_dirty); + let NextAction::Call(dispatch) = sandbox.vm.get_next_action() else { + panic!("guest dispatch is not initialized"); + }; + assert!(matches!( + request(&mut sandbox, DebugMsg::AddHwBreakpoint(dispatch)), + DebugResponse::AddHwBreakpoint(true) + )); + debugger + .send(DebugMsg::RemoveHwBreakpoint(dispatch)) + .unwrap(); + debugger + .send(DebugMsg::AddSwBreakpoint(breakpoint)) + .unwrap(); + debugger.send(DebugMsg::Continue).unwrap(); + + let result = sandbox.snapshot(); + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::VcpuStopped(VcpuStopReason::HwBp) + )); + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::RemoveHwBreakpoint(true) + )); + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::AddSwBreakpoint(true) + )); + assert!(matches!( + debugger.try_recv().unwrap(), + DebugResponse::Continue + )); + let error = result + .err() + .expect("snapshot accepted a software breakpoint"); + assert_eq!(sandbox.status(), SandboxStatus::Ready, "{error}"); + assert!(matches!( + request(&mut sandbox, DebugMsg::RemoveSwBreakpoint(breakpoint)), + DebugResponse::RemoveSwBreakpoint(true) + )); + sandbox.restore(snapshot).unwrap(); + sandbox.call::<()>("NoOp", ()).unwrap(); + } + + #[test] + fn cancelled_debug_session_can_restore_with_software_breakpoints() { + let (mut sandbox, _debugger, breakpoint) = sandbox_with_debugger(); + let interrupt = sandbox.interrupt_handle(); + sandbox + .register_host_function("HostNoOp", move || { + interrupt.kill(); + Ok(()) + }) + .unwrap(); + let snapshot = sandbox.snapshot().unwrap(); + assert!(matches!( + request(&mut sandbox, DebugMsg::AddSwBreakpoint(breakpoint)), + DebugResponse::AddSwBreakpoint(true) + )); + assert!(sandbox.restore(snapshot.clone()).is_err()); + assert_eq!(sandbox.status(), SandboxStatus::Ready); + + let error = sandbox.call::<()>("RoundTripHostNoOp", ()).unwrap_err(); + assert!(matches!(error, HyperlightError::ExecutionCanceledByHost())); + assert_eq!(sandbox.status(), SandboxStatus::Poisoned); + sandbox.restore(snapshot).unwrap(); + assert_eq!(sandbox.status(), SandboxStatus::Ready); + sandbox.call::<()>("NoOp", ()).unwrap(); + } + + #[test] + fn crash_detach_can_restore_after_breakpoint_mapping_is_lost() { + let (mut sandbox, debugger, _) = sandbox_with_debugger(); + let snapshot = sandbox.snapshot().unwrap(); + // The guard-page backing is outside the guest allocator. + let address = hyperlight_common::layout::scratch_transport_end_gpa(); + sandbox + .call::>("ReadMappedBuffer", (address, 4u64, true)) + .unwrap(); + assert!(matches!( + request(&mut sandbox, DebugMsg::AddSwBreakpoint(address)), + DebugResponse::AddSwBreakpoint(true) + )); + + let root = sandbox.vm.get_root_pt().unwrap(); + let registers = sandbox.vm.get_snapshot_sregs().unwrap(); + let memory = DebugMemoryView::new(&sandbox.mem_mgr, Vec::new()); + let mut table = root; + for shift in [39, 30, 21, 12] { + let entry_address = table + ((address >> shift) & 0x1ff) * 8; + let mut bytes = [0; 8]; + memory.read(&mut bytes, entry_address).unwrap(); + let entry = u64::from_le_bytes(bytes); + assert_ne!(entry & 1, 0); + if shift == 12 || entry & 2 == 0 { + memory.write(&[0; 8], entry_address).unwrap(); + break; + } + table = entry & 0x0000_ffff_ffff_f000; + } + sandbox.vm.apply_sregs(root, ®isters).unwrap(); + debugger.send(DebugMsg::DisableDebug).unwrap(); + + // The guest maps this address, but the hypervisor has no backing for it. + let error = sandbox + .call::>("ReadMappedBuffer", (0x1_0000_0000u64, 1u64, true)) + .unwrap_err(); + let stop = debugger.try_recv().unwrap_or_else(|receive_error| { + panic!("Missing crash stop: {receive_error:?}, guest error: {error:?}") + }); + assert!(matches!( + stop, + DebugResponse::VcpuStopped(VcpuStopReason::Crash) + )); + let response = debugger.try_recv().unwrap(); + assert!( + matches!(response, DebugResponse::DisableDebug), + "{response:?}" + ); + assert_eq!(sandbox.status(), SandboxStatus::Poisoned, "{error:?}"); + sandbox.restore(snapshot).unwrap(); + assert_eq!(sandbox.status(), SandboxStatus::Ready); + sandbox.call::<()>("NoOp", ()).unwrap(); + } + } + fn assert_virtq_attached(sbox: &Sandbox) { assert!(sbox.mem_mgr.g2h_consumer.is_some()); assert!(sbox.mem_mgr.h2g_consumer.is_some()); diff --git a/src/hyperlight_host/src/sandbox/snapshot/memory/backing.rs b/src/hyperlight_host/src/sandbox/snapshot/memory/backing.rs index 68be2c1bc..757d15740 100644 --- a/src/hyperlight_host/src/sandbox/snapshot/memory/backing.rs +++ b/src/hyperlight_host/src/sandbox/snapshot/memory/backing.rs @@ -192,7 +192,7 @@ impl SnapshotMemoryBacking { self.memory.page_table_bytes() } - #[cfg(crashdump)] + #[cfg(any(crashdump, all(gdb, target_arch = "aarch64")))] pub(crate) fn host_range(&self, layer_index: usize) -> Result<(usize, usize)> { #[cfg(not(unshared_snapshot_mem))] let backing = self diff --git a/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs b/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs index 6387faced..c00929e09 100644 --- a/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs +++ b/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs @@ -104,6 +104,30 @@ pub(crate) fn set_up_hypervisor_partition( page_size: usize, _load_info: LoadInfo, ) -> Result { + #[cfg(all(gdb, target_arch = "aarch64"))] + if config.get_guest_debug_info().is_some() + && matches!(mgr.next_action, NextAction::Initialise(_)) + && _load_info.debug_vectors.is_none() + { + #[cfg(kvm)] + let needs_vectors = !matches!( + crate::hypervisor::virtual_machine::get_available_hypervisor(), + Some(crate::hypervisor::virtual_machine::HypervisorType::Kvm) + ); + #[cfg(not(kvm))] + let needs_vectors = true; + if needs_vectors { + return Err(HyperlightVmError::Create( + crate::hypervisor::virtual_machine::VmError::Debug( + crate::hypervisor::gdb::DebugError::Unsupported( + "guest binary lacks ARM64 debug vectors", + ), + ) + .into(), + ) + .into()); + } + } // Create gdb thread if gdb is enabled and the configuration is provided #[cfg(gdb)] let gdb_conn = if let Some(DebugInfo { port }) = config.get_guest_debug_info() { @@ -128,7 +152,7 @@ pub(crate) fn set_up_hypervisor_partition( #[cfg(feature = "mem_profile")] let trace_info = MemTraceInfo::new(_load_info.info)?; - Ok(HyperlightVm::new( + let vm = HyperlightVm::new( mgr.shared_mem, mgr.scratch_mem, mgr.layout.get_pt_base_gpa(), @@ -141,7 +165,15 @@ pub(crate) fn set_up_hypervisor_partition( #[cfg(feature = "mem_profile")] trace_info, ) - .map_err(HyperlightVmError::Create)?) + .map_err(HyperlightVmError::Create)?; + #[cfg(all(gdb, target_arch = "aarch64"))] + let vm = { + let mut vm = vm; + vm.initialize_debug_vectors(_load_info.debug_vectors) + .map_err(HyperlightVmError::Create)?; + vm + }; + Ok(vm) } #[cfg(test)] @@ -161,4 +193,31 @@ mod tests { initialize_sandbox(u_sbox).unwrap(); } } + + #[cfg(all(gdb, target_arch = "aarch64"))] + #[test] + fn missing_debug_vectors_does_not_reserve_the_debug_port() { + #[cfg(kvm)] + if matches!( + crate::hypervisor::virtual_machine::get_available_hypervisor(), + Some(crate::hypervisor::virtual_machine::HypervisorType::Kvm) + ) { + return; + } + let listener = std::net::TcpListener::bind("localhost:0").unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + let mut config = crate::sandbox::SandboxConfiguration::default(); + config.set_guest_debug_info(crate::sandbox::config::DebugInfo { + port: address.port(), + }); + let mut guest = UninitializedSandbox::new( + GuestBinary::FilePath(simple_guest_as_pathbuf()), + Some(config), + ) + .unwrap(); + guest.load_info.debug_vectors = None; + assert!(initialize_sandbox(guest).is_err()); + std::net::TcpListener::bind(address).expect("failed setup retained the GDB listener"); + } } From 19159c6cb618f6291c311c6cb865bc23116f66d1 Mon Sep 17 00:00:00 2001 From: cshung <3410332+cshung@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:13:20 -0800 Subject: [PATCH 3/3] feat(aarch64): add GDB debugging for Linux MSHV MSHV forwards guest debug exceptions through intercepted HVCs. Register access uses the saved exception context and active stack pointer. Two hardware breakpoints and single-step survive replacement-VM reset. Native ARM64 GDB tests run in debug CI. Non-debug KVM use is available with default features. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: cshung <3410332+cshung@users.noreply.github.com> --- .github/workflows/dep_build_test.yml | 10 + CHANGELOG.md | 1 + Justfile | 2 +- docs/how-to-debug-a-hyperlight-guest.md | 17 +- src/hyperlight_host/build.rs | 3 +- .../hypervisor/virtual_machine/kvm/aarch64.rs | 17 + .../virtual_machine/mshv/aarch64.rs | 563 +++++++++++++++++- .../src/sandbox/uninitialized_evolve.rs | 15 + 8 files changed, 620 insertions(+), 8 deletions(-) diff --git a/.github/workflows/dep_build_test.yml b/.github/workflows/dep_build_test.yml index 3df6f79de..2c9414ccc 100644 --- a/.github/workflows/dep_build_test.yml +++ b/.github/workflows/dep_build_test.yml @@ -229,6 +229,16 @@ jobs: RUST_LOG: debug run: just test-rust-gdb-debugging ${{ inputs.config }} + - name: Run ARM64 MSHV Gdb tests + if: inputs.arch == 'arm64' && inputs.hypervisor == 'mshv3' && inputs.config == 'debug' + env: + RUST_LOG: debug + run: | + if ! command -v gdb > /dev/null 2>&1; then + sudo tdnf install -y gdb + fi + just test-rust-gdb-debugging debug mshv3 + - name: Run Rust Crashdump tests if: inputs.arch == 'X64' env: diff --git a/CHANGELOG.md b/CHANGELOG.md index ff3e4a280..84927a195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added * Add `Sandbox::recreate()`, which rebuilds a sandbox's VM and memory from a snapshot. It recovers unrecoverable sandboxes and keeps configuration, host functions, and interrupt handles by @ludfjig in https://github.com/hyperlight-dev/hyperlight/pull/1751 * ARM64 Linux guests can run on MSHV. +* ARM64 Linux/MSHV guest debugging supports software and hardware breakpoints, register and memory access, and hardware single-step. ### Fixed * Fix restore on WHP not rewinding the guest TSC on hosts with `TSC_ADJUST` support. diff --git a/Justfile b/Justfile index aea45bcf2..888986aeb 100644 --- a/Justfile +++ b/Justfile @@ -335,7 +335,7 @@ test-no-surrogate target=default-target: # runs tests that exercise gdb debugging test-rust-gdb-debugging target=default-target features="": {{ cargo-cmd }} test --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} --example guest-debugging {{ if features =="" {'--features gdb'} else { "--features gdb," + features } }} - {{ cargo-cmd }} test --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib {{ if features =="" {'--features gdb'} else { "--features gdb," + features } }} -- hypervisor::gdb:: hypervisor::hyperlight_vm::debug::tests:: gdb_recovery_tests:: + {{ cargo-cmd }} test --profile={{ if target == "debug" { "dev" } else { target } }} {{ target-triple-flag }} -p hyperlight-host --lib {{ if features =="" {'--features gdb'} else { "--features gdb," + features } }} -- hypervisor::gdb:: hypervisor::hyperlight_vm::debug::tests:: gdb_recovery_tests:: hypervisor::virtual_machine::mshv::aarch64:: # rust test for crashdump test-rust-crashdump target=default-target features="": diff --git a/docs/how-to-debug-a-hyperlight-guest.md b/docs/how-to-debug-a-hyperlight-guest.md index 2deb9894d..890baf9df 100644 --- a/docs/how-to-debug-a-hyperlight-guest.md +++ b/docs/how-to-debug-a-hyperlight-guest.md @@ -7,15 +7,26 @@ to start listening for a gdb connection. ## Supported features The Hyperlight `gdb` feature enables guest debugging to: - - stop at an entry point breakpoint which is automatically set by Hyperlight - - add and remove HW breakpoints (maximum 4 set breakpoints at a time) + - stop at the entry point - add and remove SW breakpoints - read and write registers - read and write addresses - - step/continue + - continue + - single-step - get code offset from target - stop when a crash occurs and only allow read access to the guest memory and registers +On x86_64, gdb supports up to four hardware breakpoints. +ARM64 Linux/MSHV supports two hardware breakpoints, four-byte `BRK` software +breakpoints, and hardware single-step. Guest exception vectors forward debug +exceptions through HVC. Guest binaries must retain the +`hyperlight_debug_vectors` symbol so the host can install these vectors before +the initial debug stop. Watchpoints are not advertised. + +ARM64 snapshots reject installed software breakpoints, including those added +during checkpointing. Restoring a poisoned sandbox discards its software +breakpoint state and restores clean snapshot memory. + ## Expected behavior Below is a list describing some cases of expected behavior from a gdb debug diff --git a/src/hyperlight_host/build.rs b/src/hyperlight_host/build.rs index 3ec2fe181..552a6ea3a 100644 --- a/src/hyperlight_host/build.rs +++ b/src/hyperlight_host/build.rs @@ -151,7 +151,8 @@ fn main() -> Result<()> { // Essentially the kvm and mshv3 features are ignored on windows as long as you use #[cfg(kvm)] and not #[cfg(feature = "kvm")]. // You should never use #[cfg(feature = "kvm")] or #[cfg(feature = "mshv3")] in the codebase. cfg_aliases::cfg_aliases! { - gdb: { all(feature = "gdb", debug_assertions, target_arch = "x86_64") }, + gdb_platform: { any(target_arch = "x86_64", all(target_arch = "aarch64", target_os = "linux", feature = "mshv3")) }, + gdb: { all(feature = "gdb", debug_assertions, gdb_platform) }, kvm: { all(feature = "kvm", target_os = "linux") }, mshv3: { all(feature = "mshv3", target_os = "linux") }, hvf: { all(feature = "hvf", target_os = "macos") }, diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/aarch64.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/aarch64.rs index fdd8d4963..ee3e9018f 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/aarch64.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/kvm/aarch64.rs @@ -12,6 +12,8 @@ use kvm_bindings::{ use kvm_ioctls::{Kvm, VcpuExit, VcpuFd, VmFd}; use tracing::{Span, instrument}; +#[cfg(gdb)] +use crate::hypervisor::gdb::{DebugError, DebugMemoryView, DebuggableVm}; use crate::hypervisor::regs::{CommonFpu, CommonRegisters, CommonSpecialRegisters}; use crate::hypervisor::virtual_machine::{ CreateVmError, HypervisorError, MapMemoryError, RegisterError, ResetVcpuError, RunVcpuError, @@ -21,6 +23,21 @@ use crate::hypervisor::virtual_machine::{ static KVM: LazyLock> = LazyLock::new(|| Kvm::new().map_err(|e| CreateVmError::HypervisorNotAvailable(e.into()))); +#[cfg(gdb)] +impl DebuggableVm for KvmVm { + fn translate_gva(&self, _gva: u64, _memory: &DebugMemoryView<'_>) -> Result { + Err(DebugError::Unsupported("ARM64 KVM debugging")) + } + + fn set_debug(&mut self, enable: bool) -> Result<(), DebugError> { + if enable { + Err(DebugError::Unsupported("ARM64 KVM debugging")) + } else { + Ok(()) + } + } +} + /// Return `true` if the KVM API is available #[instrument(skip_all, parent = Span::current(), level = "Trace")] pub(crate) fn is_hypervisor_present() -> bool { diff --git a/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/aarch64.rs b/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/aarch64.rs index 68dd62b41..2b131045c 100644 --- a/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/aarch64.rs +++ b/src/hyperlight_host/src/hypervisor/virtual_machine/mshv/aarch64.rs @@ -25,6 +25,8 @@ use mshv_bindings::{ use mshv_ioctls::{Mshv, VcpuFd, VmFd}; use tracing::{Span, instrument}; +#[cfg(gdb)] +use crate::hypervisor::gdb::{DebugError, DebugMemoryView, DebuggableVm, VcpuStopReason}; use crate::hypervisor::regs::{ CommonDebugRegs, CommonFpu, CommonRegisters, CommonSpecialRegisters, }; @@ -37,6 +39,60 @@ use crate::mem::memory_region::{MemoryRegion, MemoryRegionFlags}; static MSHV: LazyLock> = LazyLock::new(|| Mshv::new().map_err(|e| CreateVmError::HypervisorNotAvailable(e.into()))); +#[cfg(gdb)] +const HARDWARE_BREAKPOINTS: usize = 2; +#[cfg(gdb)] +const MDSCR_MDE: u64 = 1 << 15; +#[cfg(gdb)] +const MDSCR_KDE: u64 = 1 << 13; +#[cfg(gdb)] +const PSTATE_SS: u64 = 1 << 21; +#[cfg(gdb)] +const PSTATE_D: u64 = 1 << 9; + +#[cfg(gdb)] +#[derive(Clone, Copy, Debug, Default)] +struct DebugState { + enabled: bool, + single_step: bool, + hardware_breakpoints: [Option; HARDWARE_BREAKPOINTS], +} + +// MSHV 0.7 omits this payload. The layout follows HvArm64HypercallInterceptMessage +// in OpenVMM's vm/hv1/hvdef/src/lib.rs. +#[cfg(gdb)] +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct HypercallInterceptMessage { + header: mshv_bindings::hv_arm64_intercept_message_header, + immediate: u16, + reserved: u16, + flags: u32, + x: [u64; 18], +} + +#[cfg(gdb)] +const _: () = { + assert!(std::mem::size_of::() == 176); + assert!(std::mem::offset_of!(HypercallInterceptMessage, immediate) == 24); + assert!(std::mem::offset_of!(HypercallInterceptMessage, x) == 32); +}; + +#[cfg(gdb)] +fn hypercall_context( + message: &mshv_bindings::hv_message, +) -> Result { + if message.header.message_type != mshv_bindings::hv_message_type_HVMSG_HYPERCALL_INTERCEPT + || usize::from(message.header.payload_size) + < std::mem::size_of::() + { + return Err(RunVcpuError::DecodeIOMessage(message.header.message_type)); + } + // SAFETY: The message type and payload length match the ARM64 hypercall ABI. + // The payload contains only integer fields and may be unaligned. + Ok(unsafe { std::ptr::read_unaligned(std::ptr::addr_of!(message.u.payload).cast()) }) +} + fn mmio_write_info(syndrome: u64) -> Option<(u32, usize)> { const ISV: u64 = 1 << 24; const WNR: u64 = 1 << 6; @@ -68,6 +124,12 @@ pub(crate) struct MshvVm { vm_fd: VmFd, vcpu_fd: VcpuFd, mapped_regions: Vec, + #[cfg(gdb)] + debug_state: DebugState, + #[cfg(gdb)] + debug_exception_return: Option, + #[cfg(gdb)] + debug_step_pending: bool, } impl MshvVm { @@ -112,6 +174,15 @@ impl MshvVm { .initialize() .map_err(|e| CreateVmError::InitializeVm(e.into()))?; + #[cfg(gdb)] + vm_fd + .install_intercept(mshv_bindings::mshv_install_intercept { + access_type_mask: mshv_bindings::HV_INTERCEPT_ACCESS_MASK_EXECUTE, + intercept_type: mshv_bindings::hv_intercept_type_HV_INTERCEPT_TYPE_HYPERCALL, + ..Default::default() + }) + .map_err(|e| CreateVmError::InitializeDebug(e.to_string()))?; + let vcpu_fd = vm_fd .create_vcpu(0) .map_err(|e| CreateVmError::CreateVcpuFd(e.into()))?; @@ -127,6 +198,12 @@ impl MshvVm { vm_fd, vcpu_fd, mapped_regions: Vec::new(), + #[cfg(gdb)] + debug_state: DebugState::default(), + #[cfg(gdb)] + debug_exception_return: None, + #[cfg(gdb)] + debug_step_pending: false, }) } @@ -141,6 +218,36 @@ impl MshvVm { .map_err(|e| RegisterError::GetRegs(e.into()))?; Ok(unsafe { reg[0].value.reg64 }) } + + #[cfg(gdb)] + fn write_hardware_breakpoint( + &self, + slot: usize, + address: Option, + ) -> Result<(), RegisterError> { + let regs = [ + ( + mshv_bindings::hv_register_name_HV_ARM64_REGISTER_DBGBVR0_EL1 + slot as u32, + address.unwrap_or(0), + ), + ( + mshv_bindings::hv_register_name_HV_ARM64_REGISTER_DBGBCR0_EL1 + slot as u32, + if address.is_some() { + (15 << 5) | (1 << 1) | 1 + } else { + 0 + }, + ), + ] + .map(|(name, value)| hv_register_assoc { + name, + value: hv_register_value { reg64: value }, + ..Default::default() + }); + self.vcpu_fd + .set_reg(®s) + .map_err(|e| RegisterError::SetDebugRegs(e.into())) + } } impl VirtualMachine for MshvVm { @@ -179,10 +286,61 @@ impl VirtualMachine for MshvVm { const UNMAPPED_GPA_MESSAGE: hv_message_type = hv_message_type_HVMSG_UNMAPPED_GPA; const INVALID_GPA_ACCESS_MESSAGE: hv_message_type = hv_message_type_HVMSG_GPA_INTERCEPT; + #[cfg(gdb)] + if std::mem::take(&mut self.debug_step_pending) { + return Ok(VmExit::Debug { + reason: VcpuStopReason::DoneStep, + }); + } match self.vcpu_fd.run() { Ok(m) => { let msg_type = m.header.message_type; match msg_type { + #[cfg(gdb)] + mshv_bindings::hv_message_type_HVMSG_HYPERCALL_INTERCEPT => { + let context = hypercall_context(&m)?; + let intercepted_pc = context.header.pc; + if self.debug_state.enabled + && context.immediate + == hyperlight_common::arch::exn::DEBUG_EXCEPTION_HVC + { + let mut regs = [hv_register_assoc { + name: mshv_bindings::hv_register_name_HV_ARM64_REGISTER_ESR_EL1, + ..Default::default() + }]; + self.vcpu_fd + .get_reg(&mut regs) + .map_err(|e| RunVcpuError::Unknown(e.into()))?; + // SAFETY: ESR_EL1 is a 64-bit system register. + let ec = unsafe { regs[0].value.reg64 } >> 26; + let reason = match ec { + 0x30 | 0x31 => Some(VcpuStopReason::HwBp), + 0x32 | 0x33 => Some(VcpuStopReason::DoneStep), + 0x3c => Some(VcpuStopReason::SwBp), + _ => None, + }; + // Debug events return through ERET. Other exceptions + // resume the ordinary exception prologue. + let pc = + intercepted_pc.wrapping_add(if reason.is_some() { 4 } else { 12 }); + self.vcpu_fd + .set_reg(&[hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_PC, + value: hv_register_value { reg64: pc }, + ..Default::default() + }]) + .map_err(|e| RunVcpuError::IncrementRip(e.into()))?; + if let Some(reason) = reason { + self.debug_exception_return = Some(pc); + return Ok(VmExit::Debug { reason }); + } + return Ok(VmExit::Retry()); + } + Ok(VmExit::Unknown(format!( + "Unsupported ARM64 hypercall immediate {:#x} at PC={:#x}", + context.immediate, intercepted_pc + ))) + } UNMAPPED_GPA_MESSAGE | INVALID_GPA_ACCESS_MESSAGE => { let mem_msg = m .to_memory_info() @@ -226,6 +384,10 @@ impl VirtualMachine for MshvVm { _ => unreachable!("get_reg64 returned a non-get error"), })? }; + #[cfg(gdb)] + { + self.debug_step_pending = self.debug_state.single_step; + } Ok(VmExit::IoOut( port as u16, value.to_le_bytes()[..access_size].to_vec(), @@ -255,13 +417,25 @@ impl VirtualMachine for MshvVm { } fn regs(&self) -> std::result::Result { - let mshv_regs = self + #[allow(unused_mut)] + let mut mshv_regs = self .vcpu_fd .get_regs() .map_err(|e| RegisterError::GetRegs(e.into()))?; + #[cfg(gdb)] + if self.debug_exception_return == Some(mshv_regs.pc) { + mshv_regs.pc = + self.get_reg64(mshv_bindings::hv_register_name_HV_ARM64_REGISTER_ELR_EL1)?; + mshv_regs.pstate = + self.get_reg64(mshv_bindings::hv_register_name_HV_ARM64_REGISTER_SPSR_EL1)?; + } Ok(CommonRegisters { x: mshv_regs.regs, - sp: self.get_reg64(hv_register_name_HV_ARM64_REGISTER_SP_EL0)?, + sp: self.get_reg64(if mshv_regs.pstate & 1 == 0 { + hv_register_name_HV_ARM64_REGISTER_SP_EL0 + } else { + hv_register_name_HV_ARM64_REGISTER_SP_EL1 + })?, pc: mshv_regs.pc, pstate: mshv_regs.pstate, }) @@ -288,7 +462,11 @@ impl VirtualMachine for MshvVm { ..Default::default() }, hv_register_assoc { - name: hv_register_name_HV_ARM64_REGISTER_SP_EL0, + name: if regs.pstate & 1 == 0 { + hv_register_name_HV_ARM64_REGISTER_SP_EL0 + } else { + hv_register_name_HV_ARM64_REGISTER_SP_EL1 + }, value: hv_register_value { reg64: regs.sp }, ..Default::default() }, @@ -303,6 +481,13 @@ impl VirtualMachine for MshvVm { ..Default::default() }, ]); + #[cfg(gdb)] + if let Some(return_pc) = self.debug_exception_return + && self.get_reg64(hv_register_name_HV_ARM64_REGISTER_PC)? == return_pc + { + mshv_regs[32].name = mshv_bindings::hv_register_name_HV_ARM64_REGISTER_ELR_EL1; + mshv_regs[33].name = mshv_bindings::hv_register_name_HV_ARM64_REGISTER_SPSR_EL1; + } self.vcpu_fd .set_reg(&mshv_regs) .map_err(|e| RegisterError::SetRegs(e.into())) @@ -463,11 +648,116 @@ impl VirtualMachine for MshvVm { .map_err(|e| ResetVcpuError::Hypervisor(e.into()))?; } replacement.mapped_regions.clone_from(&self.mapped_regions); + #[cfg(gdb)] + if self.debug_state.enabled { + replacement + .set_debug(true) + .map_err(|e| ResetVcpuError::Unknown(e.to_string()))?; + for address in self.debug_state.hardware_breakpoints.iter().flatten() { + replacement + .add_hw_breakpoint(*address) + .map_err(|e| ResetVcpuError::Unknown(e.to_string()))?; + } + replacement + .set_single_step(self.debug_state.single_step) + .map_err(|e| ResetVcpuError::Unknown(e.to_string()))?; + } *self = replacement; Ok(()) } } +#[cfg(gdb)] +impl DebuggableVm for MshvVm { + fn translate_gva(&self, gva: u64, memory: &DebugMemoryView<'_>) -> Result { + let root = + self.get_reg64(hv_register_name_HV_ARM64_REGISTER_TTBR0_EL1)? & 0x0000_ffff_ffff_f000; + memory.translate_gva(gva, root) + } + + fn set_debug(&mut self, enable: bool) -> Result<(), DebugError> { + if !enable { + self.set_single_step(false)?; + for slot in 0..HARDWARE_BREAKPOINTS { + self.write_hardware_breakpoint(slot, None)?; + } + self.debug_state.hardware_breakpoints.fill(None); + } + let name = mshv_bindings::hv_register_name_HV_ARM64_REGISTER_MDSCR_EL1; + let value = self.get_reg64(name)?; + let value = if enable { + value | MDSCR_MDE | MDSCR_KDE + } else { + value & !(MDSCR_MDE | MDSCR_KDE | 1) + }; + self.vcpu_fd + .set_reg(&[hv_register_assoc { + name, + value: hv_register_value { reg64: value }, + ..Default::default() + }]) + .map_err(|e| RegisterError::SetDebugRegs(e.into()))?; + self.debug_state.enabled = enable; + Ok(()) + } + + fn set_single_step(&mut self, enable: bool) -> Result<(), DebugError> { + self.debug_step_pending = false; + let mut regs = self.regs()?; + regs.pstate = if enable { + (regs.pstate | PSTATE_SS) & !PSTATE_D + } else { + regs.pstate & !PSTATE_SS + }; + self.set_regs(®s)?; + let name = mshv_bindings::hv_register_name_HV_ARM64_REGISTER_MDSCR_EL1; + let value = self.get_reg64(name)?; + self.vcpu_fd + .set_reg(&[hv_register_assoc { + name, + value: hv_register_value { + reg64: if enable { value | 1 } else { value & !1 }, + }, + ..Default::default() + }]) + .map_err(|e| RegisterError::SetDebugRegs(e.into()))?; + self.debug_state.single_step = enable; + Ok(()) + } + + fn add_hw_breakpoint(&mut self, addr: u64) -> Result<(), DebugError> { + if !addr.is_multiple_of(4) { + return Err(DebugError::Unsupported( + "unaligned ARM64 hardware breakpoint", + )); + } + if self.debug_state.hardware_breakpoints.contains(&Some(addr)) { + return Ok(()); + } + let slot = self + .debug_state + .hardware_breakpoints + .iter() + .position(Option::is_none) + .ok_or(DebugError::TooManyHwBreakpoints(HARDWARE_BREAKPOINTS))?; + self.write_hardware_breakpoint(slot, Some(addr))?; + self.debug_state.hardware_breakpoints[slot] = Some(addr); + Ok(()) + } + + fn remove_hw_breakpoint(&mut self, addr: u64) -> Result<(), DebugError> { + let slot = self + .debug_state + .hardware_breakpoints + .iter() + .position(|value| *value == Some(addr)) + .ok_or(DebugError::HwBreakpointNotFound(addr))?; + self.write_hardware_breakpoint(slot, None)?; + self.debug_state.hardware_breakpoints[slot] = None; + Ok(()) + } +} + #[cfg(test)] mod tests { use super::mmio_write_info; @@ -484,3 +774,270 @@ mod tests { assert_eq!(mmio_write_info(1 << 6), None); } } + +#[cfg(all(test, gdb))] +mod debug_tests { + use super::*; + + #[test] + fn mmio_single_step_stops_before_the_following_instruction() { + use crate::mem::memory_region::MemoryRegionType; + use crate::mem::shared_mem::ExclusiveSharedMemory; + + let mut memory = ExclusiveSharedMemory::new(hyperlight_common::vmem::PAGE_SIZE).unwrap(); + for (index, instruction) in [0xf900_0001u32, 0xd280_1322, 0x1400_0000] + .into_iter() + .enumerate() + { + memory + .copy_from_slice(&instruction.to_le_bytes(), index * 4) + .unwrap(); + } + let hvc = + 0xd400_0002u32 | ((hyperlight_common::arch::exn::DEBUG_EXCEPTION_HVC as u32) << 5); + memory.copy_from_slice(&hvc.to_le_bytes(), 0x800).unwrap(); + memory + .copy_from_slice(&0xd69f_03e0u32.to_le_bytes(), 0x804) + .unwrap(); + let host = memory.as_slice().as_ptr() as usize; + let region = MemoryRegion { + guest_region: 0..hyperlight_common::vmem::PAGE_SIZE, + host_region: host..host + hyperlight_common::vmem::PAGE_SIZE, + flags: MemoryRegionFlags::READ | MemoryRegionFlags::EXECUTE, + region_type: MemoryRegionType::Code, + }; + let mut vm = MshvVm::new().unwrap(); + // SAFETY: `memory` outlives the VM and the guest mapping is read-only. + unsafe { vm.map_memory((0, ®ion)).unwrap() }; + vm.vcpu_fd + .set_reg(&[ + hv_register_assoc { + name: mshv_bindings::hv_register_name_HV_ARM64_REGISTER_SCTLR_EL1, + value: hv_register_value { + reg64: crate::hypervisor::regs::SCTLR_EL1_RES1, + }, + ..Default::default() + }, + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_VBAR_EL1, + value: hv_register_value { reg64: 0x800 }, + ..Default::default() + }, + ]) + .unwrap(); + let mut regs = CommonRegisters { + pstate: 0x3c4, + ..Default::default() + }; + regs.x[0] = hyperlight_common::layout::io_page().unwrap().0 + 109 * 8; + regs.x[1] = 0x55; + regs.x[2] = 0xaa; + vm.set_regs(®s).unwrap(); + vm.set_debug(true).unwrap(); + vm.set_single_step(true).unwrap(); + assert!(matches!( + vm.run_vcpu(#[cfg(feature = "trace_guest")] &mut crate::sandbox::trace::TraceContext::new()).unwrap(), + VmExit::IoOut(109, data) if data == 0x55u64.to_le_bytes() + )); + assert!(matches!( + vm.run_vcpu( + #[cfg(feature = "trace_guest")] + &mut crate::sandbox::trace::TraceContext::new() + ) + .unwrap(), + VmExit::Debug { + reason: VcpuStopReason::DoneStep + } + )); + let regs = vm.regs().unwrap(); + assert_eq!(regs.pc, 4); + assert_eq!(regs.x[2], 0xaa); + vm.set_single_step(true).unwrap(); + assert!(matches!( + vm.run_vcpu( + #[cfg(feature = "trace_guest")] + &mut crate::sandbox::trace::TraceContext::new() + ) + .unwrap(), + VmExit::Debug { + reason: VcpuStopReason::DoneStep + } + )); + let regs = vm.regs().unwrap(); + assert_eq!(regs.pc, 8); + assert_eq!(regs.x[2], 0x99); + } + + #[test] + fn decodes_hypercall_context_and_rejects_truncated_payload() { + let context = HypercallInterceptMessage { + header: mshv_bindings::hv_arm64_intercept_message_header { + pc: 0x1234, + instruction_length: 4, + ..Default::default() + }, + immediate: hyperlight_common::arch::exn::DEBUG_EXCEPTION_HVC, + ..Default::default() + }; + let mut message = mshv_bindings::hv_message::default(); + message.header.message_type = mshv_bindings::hv_message_type_HVMSG_HYPERCALL_INTERCEPT; + message.header.payload_size = std::mem::size_of_val(&context) as u8; + // SAFETY: The payload holds 240 bytes. The 176-byte context contains + // only integers and is written without alignment assumptions. + unsafe { + std::ptr::write_unaligned(std::ptr::addr_of_mut!(message.u.payload).cast(), context); + } + let decoded = hypercall_context(&message).unwrap(); + let pc = decoded.header.pc; + assert_eq!(pc, 0x1234); + assert_eq!(decoded.immediate, context.immediate); + message.header.payload_size -= 1; + assert!(matches!( + hypercall_context(&message), + Err(RunVcpuError::DecodeIOMessage(_)) + )); + message.header.payload_size += 1; + message.header.message_type = hv_message_type_HVMSG_GPA_INTERCEPT; + assert!(matches!( + hypercall_context(&message), + Err(RunVcpuError::DecodeIOMessage(_)) + )); + } + + #[test] + fn hardware_breakpoints_survive_reset_and_detach_clears_debug_state() { + let mut vm = MshvVm::new().unwrap(); + vm.set_debug(true).unwrap(); + vm.add_hw_breakpoint(0).unwrap(); + vm.add_hw_breakpoint(0).unwrap(); + vm.add_hw_breakpoint(0x1000).unwrap(); + assert!(matches!( + vm.add_hw_breakpoint(0x2000), + Err(DebugError::TooManyHwBreakpoints(2)) + )); + assert!(vm.add_hw_breakpoint(1).is_err()); + vm.remove_hw_breakpoint(0).unwrap(); + assert!(matches!( + vm.remove_hw_breakpoint(0), + Err(DebugError::HwBreakpointNotFound(0)) + )); + vm.add_hw_breakpoint(0x2000).unwrap(); + vm.set_single_step(true).unwrap(); + vm.reset_vcpu().unwrap(); + assert_eq!( + vm.get_reg64(mshv_bindings::hv_register_name_HV_ARM64_REGISTER_MDSCR_EL1) + .unwrap() + & (MDSCR_MDE | MDSCR_KDE | 1), + MDSCR_MDE | MDSCR_KDE | 1 + ); + for (slot, address) in [0x2000, 0x1000].into_iter().enumerate() { + assert_eq!( + vm.get_reg64( + mshv_bindings::hv_register_name_HV_ARM64_REGISTER_DBGBVR0_EL1 + slot as u32 + ) + .unwrap(), + address + ); + } + vm.set_debug(false).unwrap(); + assert_eq!( + vm.get_reg64(mshv_bindings::hv_register_name_HV_ARM64_REGISTER_MDSCR_EL1) + .unwrap() + & (MDSCR_MDE | MDSCR_KDE | 1), + 0 + ); + for slot in 0..HARDWARE_BREAKPOINTS { + assert_eq!( + vm.get_reg64( + mshv_bindings::hv_register_name_HV_ARM64_REGISTER_DBGBCR0_EL1 + slot as u32 + ) + .unwrap(), + 0 + ); + } + } + + #[test] + fn debug_register_access_uses_exception_context_and_active_stack() { + let mut vm = MshvVm::new().unwrap(); + let regs = [ + ( + mshv_bindings::hv_register_name_HV_ARM64_REGISTER_ELR_EL1, + 0x1000, + ), + ( + mshv_bindings::hv_register_name_HV_ARM64_REGISTER_SPSR_EL1, + 0x3c5, + ), + (hv_register_name_HV_ARM64_REGISTER_PC, 0x2000), + (hv_register_name_HV_ARM64_REGISTER_SP_EL0, 0x9000), + (hv_register_name_HV_ARM64_REGISTER_SP_EL1, 0x8000), + ] + .map(|(name, value)| hv_register_assoc { + name, + value: hv_register_value { reg64: value }, + ..Default::default() + }); + vm.vcpu_fd.set_reg(®s).unwrap(); + vm.debug_exception_return = Some(0x2000); + let mut regs = vm.regs().unwrap(); + assert_eq!(regs.pc, 0x1000); + assert_eq!(regs.sp, 0x8000); + regs.pc += 4; + regs.sp += 16; + regs.pstate = 0x3c4; + vm.set_regs(®s).unwrap(); + assert_eq!( + vm.get_reg64(mshv_bindings::hv_register_name_HV_ARM64_REGISTER_ELR_EL1) + .unwrap(), + 0x1004 + ); + assert_eq!( + vm.get_reg64(mshv_bindings::hv_register_name_HV_ARM64_REGISTER_SPSR_EL1) + .unwrap(), + 0x3c4 + ); + assert_eq!( + vm.get_reg64(hv_register_name_HV_ARM64_REGISTER_PC).unwrap(), + 0x2000 + ); + assert_eq!( + vm.get_reg64(hv_register_name_HV_ARM64_REGISTER_SP_EL0) + .unwrap(), + 0x8010 + ); + assert_eq!( + vm.get_reg64(hv_register_name_HV_ARM64_REGISTER_SP_EL1) + .unwrap(), + 0x8000 + ); + vm.vcpu_fd + .set_reg(&[ + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_PC, + value: hv_register_value { reg64: 0x2004 }, + ..Default::default() + }, + hv_register_assoc { + name: hv_register_name_HV_ARM64_REGISTER_PSTATE, + value: hv_register_value { reg64: 0x3c4 }, + ..Default::default() + }, + ]) + .unwrap(); + let mut regs = vm.regs().unwrap(); + assert_eq!(regs.pc, 0x2004); + assert_eq!(regs.sp, 0x8010); + regs.pc += 4; + vm.set_regs(®s).unwrap(); + assert_eq!( + vm.get_reg64(hv_register_name_HV_ARM64_REGISTER_PC).unwrap(), + 0x2008 + ); + assert_eq!( + vm.get_reg64(mshv_bindings::hv_register_name_HV_ARM64_REGISTER_ELR_EL1) + .unwrap(), + 0x1004 + ); + } +} diff --git a/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs b/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs index c00929e09..f61856463 100644 --- a/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs +++ b/src/hyperlight_host/src/sandbox/uninitialized_evolve.rs @@ -104,6 +104,21 @@ pub(crate) fn set_up_hypervisor_partition( page_size: usize, _load_info: LoadInfo, ) -> Result { + #[cfg(all(gdb, target_arch = "aarch64", kvm))] + if config.get_guest_debug_info().is_some() + && matches!( + crate::hypervisor::virtual_machine::get_available_hypervisor(), + Some(crate::hypervisor::virtual_machine::HypervisorType::Kvm) + ) + { + return Err(HyperlightVmError::Create( + crate::hypervisor::virtual_machine::VmError::Debug( + crate::hypervisor::gdb::DebugError::Unsupported("ARM64 KVM debugging"), + ) + .into(), + ) + .into()); + } #[cfg(all(gdb, target_arch = "aarch64"))] if config.get_guest_debug_info().is_some() && matches!(mgr.next_action, NextAction::Initialise(_))