Notes on ARM Virtualization and (p)KVM

A Hypervisor is a software layer that abstracts lower components, like hardware, and isolates upper components from each other - think VMs. These notes are about ARM’s EL2 hypervisor, since there multiple types and platforms: type 1 or bare-metal like Xen, type 2 like VirtualBox and Qemu(without KVM). And there’s KVM which is a kind of type 1.5-ish that is part of the Linux kernel; the part that runs in EL2 is called LowVisor (Christoffer Dall, 2014), the rest of it (HighVisor) runs in EL1 alongside the rest of the kernel.

On android, hypervisors like pKVM and Gunyah are EL2 orchestrators that ensures guest OSes integrity and security.

EL-(N+1) is more privileged than EL-(N), for 0 <= N < 3; so EL-3 > EL-2 > EL-1 > EL-0. But what does “privileged” exactly mean ? An answer that i like is that being more privileged means having access to more memory and more instructions, as simple as that. More in link.

ARM Virtualization Extension

https://developer.arm.com/documentation/102142/0100

ARM provides a way to run hypervisor software at EL2, and a special mode called Hyp alongside user and kernel modes.

_source: https://developer.arm.com/documentation/102142/0100/Virtualization-in-AArch64

The role of the hypervisor at EL2 is mainly:

  • Stage 2 address translation.
  • EL0 and EL1 instructions and register access trapping.

I. Stage 2 translation

_source: https://developer.arm.com/documentation/102142/0100/Stage-2-translation

Regular address translation, or 1 stage translation, is simply mapping virtual addresses to their backing physical addresses, which happens on all systems with or without a hypervisor. Though, one of the hypervisor’s main features is isolate guest OSes and VMs, and prevent one from accessing another’s physical pages. To achieve that a 2 stage address translation is designed so that every VM has its virtual addresses look like legit / “normal” addresses with a 1st translation; of course, this first one still maps to virtual addresses - Although the guest OSes think that these are physical - called Intermediate Physical Addresses, or IPA; their physical backing pages maybe scattered and not contiguous and the hypervisor chooses what pages to give to which VM, and this is where the 2nd translation is used to get the real physical addresses.

Now, if we take two VMs A and B, they could have the same or an overlapping virtual / physical address space as seen by the kernel, therefore we don’t get a valid mapping since 1 IPA address can have multiple Guest virtual addresses. To circumvent this problem, each VM is assigned a Virtual Machine IDentifier, or VMID, that is used to tag PTEs for each VM entries in the Lookaside Buffer (TLB), even when stage 2 translation is not activated - no hypervisor.

The same thing is done for applications on the same VM. On modern OSes apps have approximately the same virtual address space, but physically they don’t have the same backing pages obviously, so the same mapping problem arises; to distinguish between apps in EL0 / EL1, TLB entries are tagged with an Address Space ID, or ASID, for the 1st translation stage(source).

Note: Stage 1 translation tables addresses are stored in registers TTBR0_EL1 - for userspace addresses - & TTBR1_EL1 - for kernel space.

_source: https://developer.arm.com/documentation/102142/0100/Virtualization-host-extensions

Stage 2 table address is stored in VTTBR_EL2. The hypervisor at EL2 uses TTBR0_EL2 for its managed memory, and the SMM uses TTBR0_EL3.

I.II MMIO Accessing & Emulation

A guest VM has everything that a real machine has, and of the main components is MMIO that maps peripherals into the VM’s address space.

_source: https://developer.arm.com/documentation/102142/0100/Stage-2-translation

Assigned peripherals are real MMIO memory that is translated with stage 2, whereas Virtual peripherals are marked as fault in stage 2 which causes a fault upon accessing; the hypervisor handles it with the Exception Handler; meanwhile, the VM thinks that it accesses real MMIO.

An Exception is an event that causes a running program to be suspended and which requires to be handled by a more privileged software piece that we call Exception Handler. In x86 an Exception is called: Interrupt.

There are synchronous exceptions that are triggered as a result of a direct instruction call - invalid or privileged - or MMU-generated error by a faulty memory accesses like trying to write to a read-only memory…etc.

ARM implements instructions that intentionally cause synchronous exceptions, and that are used to define syscall interfaces to make requests to more privileged ELs:

_source: https://developer.arm.com/documentation/102412/0103/Exception-types/Synchronous-exceptions

Note: SVC is like int 0x80 in x86.

Note: RegName_ELx means that the software accessing this register needs at least “x” privilege: EL1 at least Guest OS, EL2 at least the hypervisor and EL3 Monitor / SMM.

There are asynchronous exceptions too; these are not triggered by an instruction or memory access in a running program. They could come from outside the cpu, such as the activity of a timer, …etc and they do not get handled instantly and we can not guarantee when exactly it will happen. Asynchronous exceptions are also called Interrupts in Aarch64. ARM has another type of exceptions called Physical Interrupts: Asynchronous exceptions generated by peripherals and physical devices as a way to communicate events to the processor; this category has multiple subtypes:

  • SError: System Error exceptions that are triggered when an unexpected behavior occurs.
  • IRQ and FIQ: which are expected asynchronous exceptions.

Now, after this detour on exceptions, let’s see how they are handled.

_source: https://developer.arm.com/documentation/102412/0103/Handling-exceptions/Taking-an-exception?lang=en

When a exception is triggered, the running program is paused, and the execution is redirected to the corresponding Vector Table entry that handles the exception, but just before switching to the handler code, current execution state must be saved:

  • PSTATE is saved into SPSR_ELx, where “x” is level at which the exception is handled.
  • The return address after the handling is done is stored into ELR_ELx.
  • A cause of the exception is stored in ESR_ELx.
  • In case the exception is address related, such as MMU faults, the virtual address that triggered the fault is stored in FAR_ELx.

And after, all the handling is done, the stored state is restored back with ERET instruction, and the paused program continues its execution.

Note: The execution flow returns to a preferred address depending on the exception type. Generally, for a synchronous exception, the next address following the one that generated the exception is taken. For asynchronous, the handler sets ELR_ELx to whatever instruction that was interrupted and didn’t fully execute.

Here are the key registers for exception handling: _source: https://developer.arm.com/documentation/102412/0103/Privilege-and-Exception-levels/Types-of-privilege

II. Trap and Emulate

This is the other goal of the hypervisor: Trapping - i.e: issuing exceptions - on certain EL0 and EL1 actions and handling them at a higher EL, either at EL1 at the kernel level or at the hypervisor level. The hypervisor controls what instructions / memory accesses to trap with register HCR_EL2 (Hypervisor Configuration register).

Example:

_source: https://developer.arm.com/documentation/102142/0100/Trapping-and-emulation-of-instructions

ID_AA64MMFR0_EL1 reports info about the processor’s memory support, and when accessing it is trapped by the hypervisor(so that the VM doesn’t get the complete info for example), its execution is trapped into the hypervisor which handles this operation using ESR_EL2 to get exception info, and emulates the trapped instruction, and at last resume execution in the vCPU with ERET.

Showcase - Configuration of Traps

int __kvm_vcpu_run(struct kvm_vcpu *vcpu)
{
	// [...]
	__activate_traps(vcpu);
	__timer_enable_traps(vcpu);
	// [...]
}
// arch/arm64/kvm/hyp/nvhe/switch.c
static void __activate_traps(struct kvm_vcpu *vcpu)
{
	___activate_traps(vcpu, vcpu->arch.hcr_el2); // Grabs the HCR_EL2 register
	__activate_traps_common(vcpu);
	__activate_cptr_traps(vcpu);

	if (unlikely(vcpu_is_protected(vcpu))) {
		__activate_pvm_traps_hcrx(vcpu);
		__activate_pvm_traps_hfgxtr(vcpu);
	// [...]
}
// arch/arm64/kvm/hyp/include/hyp/switch.h
static inline void ___activate_traps(struct kvm_vcpu *vcpu, u64 hcr)
{
	if (cpus_have_final_cap(ARM64_WORKAROUND_CAVIUM_TX2_219_TVM))
		hcr |= HCR_TVM;

	write_sysreg(hcr, hcr_el2);

	if (cpus_have_final_cap(ARM64_HAS_RAS_EXTN) && (hcr & HCR_VSE))
		write_sysreg_s(vcpu->arch.vsesr_el2, SYS_VSESR_EL2);
}
// arch/arm64/kvm/hyp/include/hyp/switch.h
static inline void __activate_traps_common(struct kvm_vcpu *vcpu)
{
	/* Trap on AArch32 cp15 c15 (impdef sysregs) accesses (EL1 or EL0) */
	write_sysreg(1 << 15, hstr_el2);

	/*
	 * Make sure we trap PMU access from EL0 to EL2. Also sanitize
	 * PMSELR_EL0 to make sure it never contains the cycle
	 * counter, which could make a PMXEVCNTR_EL0 access UNDEF at
	 * EL1 instead of being trapped to EL2.
	 */
	if (kvm_arm_support_pmu_v3()) {
		struct kvm_cpu_context *hctxt;

		write_sysreg(0, pmselr_el0);

		hctxt = host_data_ptr(host_ctxt);
		ctxt_sys_reg(hctxt, PMUSERENR_EL0) = read_sysreg(pmuserenr_el0);
		write_sysreg(ARMV8_PMU_USERENR_MASK, pmuserenr_el0);
		vcpu_set_flag(vcpu, PMUSERENR_ON_CPU);
	}

	*host_data_ptr(host_debug_state.mdcr_el2) = read_sysreg(mdcr_el2);
	write_sysreg(vcpu->arch.mdcr_el2, mdcr_el2);
}

III. SMMU and DMA

isolation VMs is necessary, but not sufficient; since malicious devices could DMA into pages that are reserved for a particular guest VM, thus having access to its memory. Furthermore, if a guest OS has access to a DMA controller and configures it, we get physical address mismatch since the guest kernel sees IPAs, and the DMA sees real PAs. That’s why an SMMU, IOMMU in x86, is needed for the DMA controller so that the IPAs it gets from the guest kernel are translated into valid PAs; it must be programmed by the hypervisor to enforce memory isolation.

_source: https://developer.arm.com/documentation/102142/0100/Stage-2-translation


KVM On ARM

To take full advantage of this virtualization environment that ARM has made, we need software that uses all these features and constraints in an efficient and secure manner. KVM has been well established on x86, but what about ARM ?

One of the earliest resources on KVM/ARM is this paper by Christoffer Dall & Jason Nieh, that lays the foundations of KVM on ARM, written back in 2014. Now it is a maintained part of the mainline Linux Kernel since (v3.9). Though, since ARMv8.1, KVM has 2 possible ways of being used:

  • Lowvisor/Highvisor split: where the core of KVM is run in EL2 and the rest in EL1 along the Linux kernel:

  • Running the Linux Kernel with KVM fully in EL2 (with VHE post ARMv8.1):

  • Register HCR_EL2.E2H activates or deasactivates (backwards compatibility) to VHE pre v8.1.
  • Corresponding EL2 registers for each EL1 register.
  • Routing Exceptions from EL0 to EL2 using TGE register.

KVM’s Code Dive:

Let’s review some code and get familiar with the arm64 KVM’s codebase. The source code is at arch/arm64/kvm:

$ ls kvm
arch_timer.c  emulate-nested.c  hyp           hyp_trace.h     Makefile  pauth.c     psci.c    stacktrace.c  trace.h              vgic
arm.c         fpsimd.c          hypercalls.c  inject_fault.c  mmio.c    pkvm.c      ptdump.c  sys_regs.c    trace_handle_exit.h  vgic-sys-reg-v3.c
at.c          guest.c           hyp_events.c  iommu.c         mmu.c     pmu.c       pvtime.c  sys_regs.h    trng.c               vmid.c
debug.c       handle_exit.c     hyp_trace.c   Kconfig         nested.c  pmu-emul.c  reset.c   trace_arm.h   va_layout.c

VM Creation Workflow:

The host VM calls upon /dev/kvm with ioctl command KVM_CREATE_VM to create, well, …a new guest VM:

// virt/kvm/kvm_main.c
static long kvm_dev_ioctl(struct file *filp,
			  unsigned int ioctl, unsigned long arg)
{
	int r = -EINVAL;

	switch (ioctl) {
	case KVM_GET_API_VERSION:
		if (arg)
			goto out;
		r = KVM_API_VERSION;
		break;
	case KVM_CREATE_VM:
		r = kvm_dev_ioctl_create_vm(arg); // <----
	// [...]
}

kvm_dev_ioctl_create_vm creates a struct kvm object instance, and allocates a new file where it stores the kvm instance inside the ->private_data with kvm_vm_fops as the handler ops of the new fd:

// virt/kvm/kvm_main.c
static int kvm_dev_ioctl_create_vm(unsigned long type)
{
	char fdname[ITOA_MAX_LEN + 1];
	int r, fd;
	struct kvm *kvm;
	struct file *file;

	fd = get_unused_fd_flags(O_CLOEXEC);
	if (fd < 0)
		return fd;

	snprintf(fdname, sizeof(fdname), "%d", fd);

	kvm = kvm_create_vm(type, fdname); // <----
	// [...]
	file = anon_inode_getfile("kvm-vm", &kvm_vm_fops, kvm, O_RDWR); // <----
	// [...]

The most interesting function here of course is kvm_create_vm:

// virt/kvm/kvm_main.c
static struct kvm *kvm_create_vm(unsigned long type, const char *fdname)
{
	struct kvm *kvm = kvm_arch_alloc_vm(); // <----
	struct kvm_memslots *slots;
	int r, i, j;

The allocation of the kvm object happens in:

// arch/arm64/kvm/arm.c
struct kvm *kvm_arch_alloc_vm(void)
{
	size_t sz = sizeof(struct kvm);

	if (!has_vhe())
		return kzalloc(sz, GFP_KERNEL_ACCOUNT);

	return __vmalloc(sz, GFP_KERNEL_ACCOUNT | __GFP_HIGHMEM | __GFP_ZERO);
}

Then we have more initializations: kvm’s MM, mutexes, locks, max number of VCPUs, refcounting…etc

	// [...]
	if (!kvm)
		return ERR_PTR(-ENOMEM);

	KVM_MMU_LOCK_INIT(kvm);
	mmgrab(current->mm);
	kvm->mm = current->mm;
	kvm_eventfd_init(kvm);
	mutex_init(&kvm->lock);
	// [...]
	kvm->max_vcpus = KVM_MAX_VCPUS;

	BUILD_BUG_ON(KVM_MEM_SLOTS_NUM > SHRT_MAX);

	/*
	 * Force subsequent debugfs file creations to fail if the VM directory
	 * is not created (by kvm_create_vm_debugfs()).
	 */
	kvm->debugfs_dentry = ERR_PTR(-ENOENT);

	snprintf(kvm->stats_id, sizeof(kvm->stats_id), "kvm-%d",
		 task_pid_nr(current));

	r = -ENOMEM;
	if (init_srcu_struct(&kvm->srcu))
		goto out_err_no_srcu;
	if (init_srcu_struct(&kvm->irq_srcu))
		goto out_err_no_irq_srcu;

	r = kvm_init_irq_routing(kvm);
	if (r)
		goto out_err_no_irq_routing;

	refcount_set(&kvm->users_count, 1);
	// [...]

memslot inits:

	for (i = 0; i < kvm_arch_nr_memslot_as_ids(kvm); i++) {
		for (j = 0; j < 2; j++) {
			slots = &kvm->__memslots[i][j];

			atomic_long_set(&slots->last_used_slot, (unsigned long)NULL);
			slots->hva_tree = RB_ROOT_CACHED;
			slots->gfn_tree = RB_ROOT;
			hash_init(slots->id_hash);
			slots->node_idx = j;

			/* Generations must be different for each address space. */
			slots->generation = i;
		}

		rcu_assign_pointer(kvm->memslots[i], &kvm->__memslots[i][0]);
	}

Init buses:

	r = -ENOMEM;
	for (i = 0; i < KVM_NR_BUSES; i++) {
		rcu_assign_pointer(kvm->buses[i],
			kzalloc(sizeof(struct kvm_io_bus), GFP_KERNEL_ACCOUNT));
		if (!kvm->buses[i])
			goto out_err_no_arch_destroy_vm;
	}

Initializing the VM:

	r = kvm_arch_init_vm(kvm, type);

This is where pkvm_init_host_vm inits pKVM if the host specifies it in type with flag KVM_VM_TYPE_ARM_PROTECTED :

int pkvm_init_host_vm(struct kvm *host_kvm, unsigned long type)
{
	if (!(type & KVM_VM_TYPE_ARM_PROTECTED))
		return 0;

	if (!is_protected_kvm_enabled())
		return -EINVAL;

	host_kvm->arch.pkvm.pvmfw_load_addr = PVMFW_INVALID_LOAD_ADDR;
	host_kvm->arch.pkvm.enabled = true;
	return 0;
}

The second stage MMU is setup with :

	ret = kvm_init_stage2_mmu(kvm, &kvm->arch.mmu, type);
// arch/arm64/kvm/mmu.c
int kvm_init_stage2_mmu(struct kvm *kvm, struct kvm_s2_mmu *mmu, unsigned long type)
{
	int cpu, err;
	struct kvm_pgtable *pgt;

	kvm->arch.pkvm.pinned_pages = RB_ROOT_CACHED;
	// if `mmu->pgt` is not null -> BUG
	if (mmu->pgt != NULL) {
		if (kvm_is_nested_s2_mmu(kvm, mmu))
			return 0;

		kvm_err("kvm_arch already initialized?\n");
		return -EINVAL;
	}

		err = kvm_init_ipa_range(mmu, type);
	if (err)
		return err;

	pgt = kzalloc(sizeof(*pgt), GFP_KERNEL_ACCOUNT);
	if (!pgt)
		return -ENOMEM;

	mmu->arch = &kvm->arch;
	err = KVM_PGT_FN(kvm_pgtable_stage2_init)(pgt, mmu, &kvm_s2_mm_ops, &kvm_s2_pte_ops);
	if (err)
		goto out_free_pgtable;

	mmu->pgt = pgt;
	if (is_protected_kvm_enabled())
		return 0;

	mmu->last_vcpu_ran = alloc_percpu(typeof(*mmu->last_vcpu_ran));
	if (!mmu->last_vcpu_ran) {
		err = -ENOMEM;
		goto out_destroy_pgtable;
	}

	for_each_possible_cpu(cpu)
		*per_cpu_ptr(mmu->last_vcpu_ran, cpu) = -1;

	 /* The eager page splitting is disabled by default */
	mmu->split_page_chunk_size = KVM_ARM_EAGER_SPLIT_CHUNK_SIZE_DEFAULT;
	mmu->split_page_cache.gfp_zero = __GFP_ZERO;

	mmu->pgd_phys = __pa(pgt->pgd);

	if (kvm_is_nested_s2_mmu(kvm, mmu))
		kvm_init_nested_s2_mmu(mmu);

	return 0;

out_destroy_pgtable:
	KVM_PGT_FN(kvm_pgtable_stage2_destroy)(pgt);
out_free_pgtable:
	kfree(pgt);
	return err;
}

Next, Virtual Generic Interrupt Controller, or VGIC, is setup:

kvm_vgic_early_init(kvm);
// arch/arm64/kvm/vgic/vgic-init.c
/**
 * kvm_vgic_early_init() - Initialize static VGIC VCPU data structures
 * @kvm: The VM whose VGIC distributor should be initialized
 *
 * Only do initialization of static structures that don't require any
 * allocation or sizing information from userspace.  vgic_init() called
 * kvm_vgic_dist_init() which takes care of the rest.
 */
void kvm_vgic_early_init(struct kvm *kvm)
{
	struct vgic_dist *dist = &kvm->arch.vgic;

	xa_init_flags(&dist->lpi_xa, XA_FLAGS_LOCK_IRQ);
}

Then the virtual timer:

kvm_timer_init_vm(kvm);
// arch/arm64/kvm/arch_timer.c
void kvm_timer_init_vm(struct kvm *kvm)
{
	for (int i = 0; i < NR_KVM_TIMERS; i++)
		kvm->arch.timer_data.ppi[i] = default_ppi[i];
}

Hypercalls initialization:

kvm_arm_init_hypercalls(kvm);
// arch/arm64/kvm/hypercalls.c
void kvm_arm_init_hypercalls(struct kvm *kvm)
{
	struct kvm_smccc_features *smccc_feat = &kvm->arch.smccc_feat;

	smccc_feat->std_bmap = KVM_ARM_SMCCC_STD_FEATURES;
	smccc_feat->std_hyp_bmap = KVM_ARM_SMCCC_STD_HYP_FEATURES;
	smccc_feat->vendor_hyp_bmap = KVM_ARM_SMCCC_VENDOR_HYP_FEATURES;

	mt_init(&kvm->arch.smccc_filter);
}

Continuing On The Workflow:

kvm_enable_virtualization():

// virt/kvm/kvm_main.c

static int kvm_enable_virtualization(void)
{
	int r;

	guard(mutex)(&kvm_usage_lock);

	if (kvm_usage_count++)
		return 0;

	kvm_arch_enable_virtualization();

	r = cpuhp_setup_state(CPUHP_AP_KVM_ONLINE, "kvm/cpu:online",
			      kvm_online_cpu, kvm_offline_cpu);
	if (r)
		goto err_cpuhp;
	// [...]
}

Init the MMU notifier:

// virt/kvm/kvm_main.c
static int kvm_init_mmu_notifier(struct kvm *kvm)
{
	kvm->mmu_notifier.ops = &kvm_mmu_notifier_ops;
	return mmu_notifier_register(&kvm->mmu_notifier, current->mm);
}

MMIO init pages and ops:

// virt/kvm/coalesced_mmio.c
static const struct kvm_io_device_ops coalesced_mmio_ops = {
	.write      = coalesced_mmio_write,
	.destructor = coalesced_mmio_destructor,
};
int kvm_coalesced_mmio_init(struct kvm *kvm)
{
	struct page *page;

	page = alloc_page(GFP_KERNEL_ACCOUNT | __GFP_ZERO);
	if (!page)
		return -ENOMEM;

	kvm->coalesced_mmio_ring = page_address(page);

	/*
	 * We're using this spinlock to sync access to the coalesced ring.
	 * The list doesn't need its own lock since device registration and
	 * unregistration should only happen when kvm->slots_lock is held.
	 */
	spin_lock_init(&kvm->ring_lock);
	INIT_LIST_HEAD(&kvm->coalesced_zones);

	return 0;
}

KVM Fd Usage:

Now that a VM is setup, and we got its fd, we can interact with it through kvm_vm_ioctl:

// virt/kvm/kvm_main.c
static struct file_operations kvm_vm_fops = {
	.release        = kvm_vm_release,
	.unlocked_ioctl = kvm_vm_ioctl,
	.llseek		= noop_llseek,
	KVM_COMPAT(kvm_vm_compat_ioctl),
};

The ioctl handler kvm_vm_ioctl is the main entry to configure the VM:

// virt/kvm/kvm_main.c
static long kvm_vm_ioctl(struct file *filp,
			   unsigned int ioctl, unsigned long arg)
{
	struct kvm *kvm = filp->private_data;
	void __user *argp = (void __user *)arg;
	int r;

	if (kvm->mm != current->mm || kvm->vm_dead)
		return -EIO;
	switch (ioctl) {
	case KVM_CREATE_VCPU:
		r = kvm_vm_ioctl_create_vcpu(kvm, arg);
		break;
	case KVM_ENABLE_CAP: {
		struct kvm_enable_cap cap;

		r = -EFAULT;
		if (copy_from_user(&cap, argp, sizeof(cap)))
			goto out;
		r = kvm_vm_ioctl_enable_cap_generic(kvm, &cap);
		break;
	}
	case KVM_SET_USER_MEMORY_REGION2:
	case KVM_SET_USER_MEMORY_REGION: {
		struct kvm_userspace_memory_region2 mem;
		unsigned long size;

		if (ioctl == KVM_SET_USER_MEMORY_REGION) {
			/*
			 * Fields beyond struct kvm_userspace_memory_region shouldn't be
			 * accessed, but avoid leaking kernel memory in case of a bug.
			 */
			memset(&mem, 0, sizeof(mem));
			size = sizeof(struct kvm_userspace_memory_region);
		} else {
			size = sizeof(struct kvm_userspace_memory_region2);
		}

Protected VM Creation:

// arch/arm64/kvm/pkvm.c
int pkvm_create_hyp_vm(struct kvm *host_kvm)
{
	int ret = 0;

	mutex_lock(&host_kvm->arch.config_lock);
	if (!pkvm_is_hyp_created(host_kvm))
		ret = __pkvm_create_hyp_vm(host_kvm);
	mutex_unlock(&host_kvm->arch.config_lock);

	return ret;
}

__pkvm_create_hyp_vm allocates the Hypervisor objects: VM and VCPUs.

// arch/arm64/kvm/pkvm.c
static int __pkvm_create_hyp_vm(struct kvm *host_kvm)
{
	size_t pgd_sz;
	void *pgd;
	int ret;

	if (host_kvm->created_vcpus < 1)
		return -EINVAL;

	pgd_sz = kvm_pgtable_stage2_pgd_size(host_kvm->arch.mmu.vtcr);

	/*
	 * The PGD pages will be reclaimed using a hyp_memcache which implies
	 * page granularity. So, use alloc_pages_exact() to get individual
	 * refcounts.
	 */
	pgd = alloc_pages_exact(pgd_sz, GFP_KERNEL_ACCOUNT);
	if (!pgd)
		return -ENOMEM;
	atomic64_add(pgd_sz, &host_kvm->stat.protected_hyp_mem);

	init_hyp_stage2_memcache(&host_kvm->arch.pkvm.stage2_teardown_mc);

	/* Donate the VM memory to hyp and let hyp initialize it. */
	ret = kvm_call_refill_hyp_nvhe(__pkvm_init_vm, host_kvm, pgd);
	if (ret < 0)
		goto free_pgd;

	WRITE_ONCE(host_kvm->arch.pkvm.handle, ret);

	kvm_account_pgtable_pages(pgd, pgd_sz >> PAGE_SHIFT);

	return __pkvm_notify_guest_vm_avail_retry(host_kvm, FFA_VM_CREATION_MSG);
free_pgd:
	free_pages_exact(pgd, pgd_sz);
	atomic64_sub(pgd_sz, &host_kvm->stat.protected_hyp_mem);

	return ret;
}

The actual running of the guest on a vcpu:

Using the vcpu ioctl handler with command KVM_RUN:

// arch/arm64/kvm/arm.c
/*
 * Actually run the vCPU, entering an RCU extended quiescent state (EQS) while
 * the vCPU is running.
 *
 * This must be noinstr as instrumentation may make use of RCU, and this is not
 * safe during the EQS.
 */
static int noinstr kvm_arm_vcpu_enter_exit(struct kvm_vcpu *vcpu)
{
	int ret;

	guest_state_enter_irqoff();
	ret = kvm_call_hyp_ret(__kvm_vcpu_run, vcpu); // <----
	guest_state_exit_irqoff();

	return ret;
}

KVM Object Structure:

// include/linux/kvm_host.h
struct kvm {
#ifdef KVM_HAVE_MMU_RWLOCK
	rwlock_t mmu_lock;
#else
	spinlock_t mmu_lock;
#endif /* KVM_HAVE_MMU_RWLOCK */

	struct mutex slots_lock;

	/*
	 * Protects the arch-specific fields of struct kvm_memory_slots in
	 * use by the VM. To be used under the slots_lock (above) or in a
	 * kvm->srcu critical section where acquiring the slots_lock would
	 * lead to deadlock with the synchronize_srcu in
	 * kvm_swap_active_memslots().
	 */
	struct mutex slots_arch_lock;
	struct mm_struct *mm; /* userspace tied to this vm */
	unsigned long nr_memslot_pages;
	/* The two memslot sets - active and inactive (per address space) */
	struct kvm_memslots __memslots[KVM_MAX_NR_ADDRESS_SPACES][2];
	/* The current active memslot set for each address space */
	struct kvm_memslots __rcu *memslots[KVM_MAX_NR_ADDRESS_SPACES];
	struct xarray vcpu_array;
	/*
	 * Protected by slots_lock, but can be read outside if an
	 * incorrect answer is acceptable.
	 */
	atomic_t nr_memslots_dirty_logging;

	/* Used to wait for completion of MMU notifiers.  */
	spinlock_t mn_invalidate_lock;
	unsigned long mn_active_invalidate_count;
	struct rcuwait mn_memslots_update_rcuwait;

	/* For management / invalidation of gfn_to_pfn_caches */
	spinlock_t gpc_lock;
	struct list_head gpc_list;

	/*
	 * created_vcpus is protected by kvm->lock, and is incremented
	 * at the beginning of KVM_CREATE_VCPU.  online_vcpus is only
	 * incremented after storing the kvm_vcpu pointer in vcpus,
	 * and is accessed atomically.
	 */
	atomic_t online_vcpus;
	int max_vcpus;
	int created_vcpus;
	int last_boosted_vcpu;
	struct list_head vm_list;
	struct mutex lock;
	struct kvm_io_bus __rcu *buses[KVM_NR_BUSES];
#ifdef CONFIG_HAVE_KVM_IRQCHIP
	struct {
		spinlock_t        lock;
		struct list_head  items;
		/* resampler_list update side is protected by resampler_lock. */
		struct list_head  resampler_list;
		struct mutex      resampler_lock;
	} irqfds;
#endif
	struct list_head ioeventfds;
	struct kvm_vm_stat stat;
	struct kvm_arch arch;
	refcount_t users_count;
#ifdef CONFIG_KVM_MMIO
	struct kvm_coalesced_mmio_ring *coalesced_mmio_ring;
	spinlock_t ring_lock;
	struct list_head coalesced_zones;
#endif

	struct mutex irq_lock;
#ifdef CONFIG_HAVE_KVM_IRQCHIP
	/*
	 * Update side is protected by irq_lock.
	 */
	struct kvm_irq_routing_table __rcu *irq_routing;

	struct hlist_head irq_ack_notifier_list;
#endif

#ifdef CONFIG_KVM_GENERIC_MMU_NOTIFIER
	struct mmu_notifier mmu_notifier;
	unsigned long mmu_invalidate_seq;
	long mmu_invalidate_in_progress;
	gfn_t mmu_invalidate_range_start;
	gfn_t mmu_invalidate_range_end;
#endif
	struct list_head devices;
	u64 manual_dirty_log_protect;
	struct dentry *debugfs_dentry;
	struct kvm_stat_data **debugfs_stat_data;
	struct srcu_struct srcu;
	struct srcu_struct irq_srcu;
	pid_t userspace_pid;
	bool override_halt_poll_ns;
	unsigned int max_halt_poll_ns;
	u32 dirty_ring_size;
	bool dirty_ring_with_bitmap;
	bool vm_bugged;
	bool vm_dead;

#ifdef CONFIG_HAVE_KVM_PM_NOTIFIER
	struct notifier_block pm_notifier;
#endif
#ifdef CONFIG_KVM_GENERIC_MEMORY_ATTRIBUTES
	/* Protected by slots_locks (for writes) and RCU (for reads) */
	struct xarray mem_attr_array;
#endif
	char stats_id[KVM_STATS_NAME_SIZE];
};

pKVM: Protected KVM

Everything up to this point described a hypervisor that the host trusts: the Linux kernel in EL1 is the one that programs stage-2, decides which pages a guest gets, and can read any guest’s memory at will. pKVM (protected KVM) flips the trust model around. The insight is that the Android host kernel is huge (millions of LoC, hundreds of drivers) and therefore a big attack surface; we don’t want a compromised host kernel to be able to peek into a protected guest (think DRM, a keystore VM, …).

So pKVM keeps the split we saw earlier (the small LowVisor in EL2, the big HighVisor in EL1) but de-privileges the host: after boot, the EL1 host is treated as just another (highly privileged) VM that the EL2 hyp does not trust. The EL2 component becomes the only entity that can touch stage-2 page tables, and it enforces that the host can no longer access memory that has been handed to a protected guest.

The snippets below are from the Android common kernel (v6.6.127), and are organized to mirror the concepts introduced earlier in these notes: the LowVisor/HighVisor split, stage-2 as the isolation primitive, trap-and-emulate, exceptions/vector handling, and DMA/SMMU. All of the EL2 code lives under arch/arm64/kvm/hyp/nvhe/; “nvhe” = non-VHE, i.e. the hyp runs as a separate EL2 payload rather than sharing EL1 with the host (that’s the split from the first KVM diagram above).

1. De-privileging the host: the EL2 trap vector

Recall the Trap and Emulate section: EL2 controls what EL1/EL0 actions trap up to it via HCR_EL2. Under pKVM this is the mechanism used to keep the host in check. Every host trap into EL2, whether a hypercall (HVC), a secure-monitor call (SMC), or a stage-2 fault from the host itself, funnels through a single dispatcher, the EL2 equivalent of a Vector Table entry:

// arch/arm64/kvm/hyp/nvhe/hyp-main.c
void handle_trap(struct kvm_cpu_context *host_ctxt)
{
	u64 esr = read_sysreg_el2(SYS_ESR);   // ESR_EL2: why did the host trap? (see Exceptions section)

	__hyp_enter();

	switch (ESR_ELx_EC(esr)) {
	case ESR_ELx_EC_HVC64:                 // host asked hyp to do something -> hypercall
		handle_host_hcall(host_ctxt);
		break;
	case ESR_ELx_EC_SMC64:                 // host issued an SMC -> hyp mediates (PSCI, FF-A...)
		handle_host_smc(host_ctxt);
		break;
	case ESR_ELx_EC_IABT_LOW:              // host took an instruction/data abort:
	case ESR_ELx_EC_DABT_LOW:              // it touched a page it isn't allowed to
		handle_host_mem_abort(host_ctxt);
		break;
	default:
		BUG_ON(!READ_ONCE(default_trap_handler) || !default_trap_handler(&host_ctxt->regs));
	}

	__hyp_exit();
}

This is exactly the “a more privileged piece of software handles the exception” idea from the Exceptions section, except here the host kernel itself is the less-privileged program being policed, and ESR_EL2 carries the exception class that selects the handler.

The host can only ask EL2 to do things through a fixed, numbered menu of hypercalls. handle_host_hcall looks the request up in a jump table and refuses anything out of range; the host cannot call arbitrary EL2 code:

// arch/arm64/kvm/hyp/nvhe/hyp-main.c
typedef void (*hcall_t)(struct kvm_cpu_context *);
#define HANDLE_FUNC(x)	[__KVM_HOST_SMCCC_FUNC_##x] = (hcall_t)handle_##x

static const hcall_t host_hcall[] = {
	HANDLE_FUNC(__pkvm_init),              // one-time EL2 bring-up
	HANDLE_FUNC(__pkvm_prot_finalize),     // "lock the door" (see §2)
	HANDLE_FUNC(__pkvm_host_share_hyp),    // host lends a page to hyp
	HANDLE_FUNC(__pkvm_host_unshare_hyp),
	HANDLE_FUNC(__pkvm_host_map_guest),    // host donates/shares a page to a guest
	HANDLE_FUNC(__pkvm_init_vm),           // create a protected VM at EL2
	HANDLE_FUNC(__pkvm_init_vcpu),
	HANDLE_FUNC(__kvm_vcpu_run),           // actually run a guest vCPU
	/* ... */
};

static void handle_host_hcall(struct kvm_cpu_context *host_ctxt)
{
	DECLARE_REG(unsigned long, id, host_ctxt, 0);   // hypercall number lives in x0
	hcall_t hfn;

	id -= KVM_HOST_SMCCC_ID(0);

	if (unlikely(id >= ARRAY_SIZE(host_hcall)))      // out of range -> rejected
		goto inval;
	hfn = host_hcall[id];
	if (unlikely(!hfn))
		goto inval;

	cpu_reg(host_ctxt, 0) = SMCCC_RET_SUCCESS;
	hfn(host_ctxt);
	return;
inval:
	cpu_reg(host_ctxt, 0) = SMCCC_RET_NOT_SUPPORTED;
}

2. Stage-2 as the isolation primitive: the host stage-2 and “closing the door”

Earlier we saw stage-2 translation used to isolate guests (IPA → PA, tagged by VMID). pKVM’s twist: it puts the host behind a stage-2 too. At boot, EL2 builds an identity-mapped stage-2 for the host (IPA == PA, so the host still sees “all” of memory), then uses that table as a permission layer it can revoke pages from later.

// arch/arm64/kvm/hyp/nvhe/mem_protect.c
int kvm_host_prepare_stage2(void *pgt_pool_base)
{
	struct kvm_s2_mmu *mmu = &host_mmu.arch.mmu;

	prepare_host_vtcr();
	hyp_spin_lock_init(&host_mmu.lock);
	mmu->arch = &host_mmu.arch;
	/* ... allocate the page-table pool ... */

	ret = __kvm_pgtable_stage2_init(&host_mmu.pgt, mmu, &host_mmu.mm_ops,
					KVM_HOST_S2_FLAGS, &host_s2_pte_ops);
	if (ret)
		return ret;

	mmu->pgd_phys = __hyp_pa(host_mmu.pgt.pgd);
	mmu->pgt = &host_mmu.pgt;
	atomic64_set(&mmu->vmid.id, 0);        // the host runs under VMID 0

	return prepopulate_host_stage2();      // identity-map all of DRAM to the host
}

The host’s stage-2 is only enforced once EL2 flips HCR_EL2.VM on. This is the moment the host loses its god-mode: from here on, any host access to a page EL2 hasn’t granted it will fault straight into handle_host_mem_abort. Note this is the same HCR_EL2 bit from the trap-and-emulate section, now used to arm the host’s own cage:

// arch/arm64/kvm/hyp/nvhe/mem_protect.c
int __pkvm_prot_finalize(void)
{
	struct kvm_s2_mmu *mmu = &host_mmu.arch.mmu;
	struct kvm_nvhe_init_params *params = this_cpu_ptr(&kvm_init_params);

	if (params->hcr_el2 & HCR_VM)
		return -EPERM;                 // already finalized on this CPU

	params->vttbr = kvm_get_vttbr(mmu);    // point VTTBR_EL2 at the host's stage-2 pgd
	params->vtcr  = host_mmu.arch.vtcr;
	params->hcr_el2 |= HCR_VM;              // <-- turn stage-2 ON for the host

	kvm_flush_dcache_to_poc(params, sizeof(*params));

	write_sysreg(params->hcr_el2, hcr_el2);
	__load_stage2(&host_mmu.arch.mmu, &host_mmu.arch);

	__tlbi(vmalls12e1);                    // drop stale stage-1&2 TLB entries
	dsb(nsh);
	isb();
	return 0;
}

VTTBR_EL2 is the same register from the Stage-2 section, but here it points at the host’s stage-2 table, which is the whole point of pKVM.

Who owns each page? EL2 tracks ownership per physical page so it can answer “is the host allowed to touch this?”. The bookkeeping lives in a small state machine encoded partly in spare stage-2 PTE software bits and partly in the hyp’s struct hyp_page:

// arch/arm64/kvm/hyp/include/nvhe/memory.h
enum pkvm_page_state {
	PKVM_PAGE_OWNED			= 0ULL,          // sole owner, exclusive access
	PKVM_PAGE_SHARED_OWNED		= BIT(0),        // I own it, but lent it out
	PKVM_PAGE_SHARED_BORROWED	= BIT(1),        // someone lent it to me
	PKVM_PAGE_MMIO_DMA		= BIT(0) | BIT(1),
	/* ... */
	PKVM_NOPAGE			= BIT(3),        // not accessible to this owner at all
};

struct hyp_page {
	unsigned short refcount;
	u8 order;
	/* Host (non-meta) state. Guarded by the host stage-2 lock. */
	enum pkvm_page_state host_state : 8;
};

// arch/arm64/include/asm/kvm_pgtable.h: the "owners" pKVM knows about
enum pkvm_component_id {
	PKVM_ID_HOST,        // the (untrusted) Linux host in EL1
	PKVM_ID_HYP,         // EL2 itself
	PKVM_ID_FFA,         // the secure world / TrustZone, via FF-A
	PKVM_ID_GUEST,       // a normal (non-protected) guest
	PKVM_ID_PROTECTED,   // a protected guest
};

Every ownership change is expressed as a transition between two of those components: an initiator who currently owns the page and a completer who receives access. Sharing a host page with EL2 (e.g. to set up a shared ring buffer) is one such transition, and it is done under both components’ locks so the states can’t race:

// arch/arm64/kvm/hyp/nvhe/mem_protect.c
int __pkvm_host_share_hyp(u64 pfn)
{
	u64 host_addr = hyp_pfn_to_phys(pfn);
	u64 hyp_addr  = (u64)__hyp_va(host_addr);
	struct pkvm_mem_transition share = {
		.nr_pages	= 1,
		.initiator	= {
			.id	= PKVM_ID_HOST,                 // host owns it now...
			.addr	= host_addr,
			.host	= { .completer_addr = hyp_addr },
		},
		.completer	= {
			.id	= PKVM_ID_HYP,                  // ...and lends it to EL2
			.prot	= default_hyp_prot(host_addr),
		},
	};
	int ret;

	host_lock_component();
	hyp_lock_component();

	ret = do_share(&share, &nr_shared);      // host: OWNED -> SHARED_OWNED
	                                         // hyp:  NOPAGE -> SHARED_BORROWED
	hyp_unlock_component();
	host_unlock_component();
	return ret;
}

3. When the host touches a forbidden page: the mem-abort handler

This closes the loop with the Exceptions section. Once stage-2 is armed, a host access to a page it doesn’t own is a stage-2 data/instruction abort taken to EL2. FAR_EL2/HPFAR_EL2 give the faulting address (exactly the FAR_ELx role we saw), and EL2 decides whether to lazily map the page in or to punish the host:

// arch/arm64/kvm/hyp/nvhe/mem_protect.c
void handle_host_mem_abort(struct kvm_cpu_context *host_ctxt)
{
	struct kvm_vcpu_fault_info fault;
	u64 esr, addr;
	int ret = -EPERM;

	esr = read_sysreg_el2(SYS_ESR);          // ESR_EL2: fault class + status
	if (!__get_fault_info(esr, &fault)) {
		addr = (u64)-1;
		goto return_to_host;                 // raced with a pgtable change, retry
	}

	/* Reconstruct the faulting IPA from HPFAR_EL2 (page) + FAR_EL2 (offset) */
	addr  = (fault.hpfar_el2 & HPFAR_MASK) << 8;
	addr |= fault.far_el2 & FAR_MASK;

	/* An MMIO access the host is allowed to make? let the IOMMU layer emulate it */
	if (is_dabt(esr) && !addr_is_memory(addr) &&
	    kvm_iommu_host_dabt_handler(host_ctxt, esr, addr))
		goto return_to_host;

	/* Ordinary RAM the host may own: (re)establish its identity mapping. */
	if (ret == -EPERM)
		ret = host_stage2_idmap(addr);

	if ((esr & ESR_ELx_FSC_TYPE) == ESR_ELx_FSC_PERM)
		ret = handle_host_perm_fault(host_ctxt, esr, addr);

	if (ret == -EPERM)
		host_inject_abort(host_ctxt);        // host touched a *protected* page -> fault back to it
	else
		BUG_ON(ret && ret != -EAGAIN);

return_to_host:
	trace_host_mem_abort(esr, addr);
}

host_stage2_idmap is the “assigned peripheral / normal RAM” branch from the MMIO section: memory the host legitimately owns is faulted in lazily and identity-mapped. A page that belongs to a protected guest never reaches that branch; it stays PKVM_NOPAGE for the host and the access is bounced back as an abort. That single asymmetry is what makes a compromised host unable to read guest secrets.

4. Creating a protected VM at EL2

Back in the VM-creation workflow, pkvm_create_hyp_vm__pkvm_create_hyp_vm (in EL1) donated a PGD to the hyp and issued the __pkvm_init_vm hypercall. Here is the EL2 side of that call: it builds the hyp’s own trusted shadow of the VM, out of memory the host donates (and therefore loses access to):

// arch/arm64/kvm/hyp/nvhe/pkvm.c
int __pkvm_init_vm(struct kvm *host_kvm, unsigned long pgd_hva)
{
	struct pkvm_hyp_vm *hyp_vm;
	unsigned int nr_vcpus;
	void *pgd;

	/* Pin the host's kvm struct so it can't be freed/altered underneath us */
	ret = hyp_pin_shared_mem(host_kvm, host_kvm + 1);
	if (ret)
		return ret;

	nr_vcpus = READ_ONCE(host_kvm->created_vcpus);

	/* EL2-private allocation the host cannot see */
	hyp_vm = hyp_alloc_account(pkvm_get_hyp_vm_size(nr_vcpus), host_kvm);

	/* The stage-2 PGD the host donated becomes hyp-owned memory */
	pgd = map_donated_memory_noclear(pgd_hva, pgd_size);

	init_pkvm_hyp_vm(host_kvm, hyp_vm, last_ran, nr_vcpus);

	hyp_write_lock(&vm_table_lock);
	ret = insert_vm_table_entry(host_kvm, hyp_vm);      // give it a handle
	ret = kvm_guest_prepare_stage2(hyp_vm, pgd);        // build the guest's stage-2
	hyp_write_unlock(&vm_table_lock);

	return hyp_vm->kvm.arch.pkvm.handle;                // host only ever gets an opaque handle
}

The hyp’s private mirror of the VM keeps a backpointer to the untrusted host structure but never trusts it; note the comments straight from the source:

// arch/arm64/kvm/hyp/include/nvhe/pkvm.h
struct pkvm_hyp_vm {
	struct kvm kvm;

	/* Backpointer to the host's (untrusted) KVM instance. */
	struct kvm *host_kvm;

	/* The guest's stage-2 page-table managed by the hypervisor. */
	struct kvm_pgtable pgt;
	struct hyp_pool    pool;
	hyp_spinlock_t     pgtable_lock;

	unsigned int nr_vcpus;
	bool is_dying;
	struct pkvm_hyp_vcpu *vcpus[];
};

struct pkvm_hyp_vcpu {
	struct kvm_vcpu vcpu;
	/* Backpointer to the host's (untrusted) vCPU instance. */
	struct kvm_vcpu *host_vcpu;
	u32 exit_code;
	int power_state;
};

The host references this VM only through the opaque pkvm_handle_t returned above: it can ask EL2 to run/map/tear down the VM, but it can never dereference the hyp’s copy.

5. Per-guest trap configuration (trap-and-emulate, per VM)

The Configuration of Traps showcase earlier set HCR_EL2 for a vCPU. pKVM does the same, but for a protected guest it stops trusting the host’s requested HCR_EL2 and installs a hardened trap configuration so the host can’t, say, disable a trap to snoop on the guest:

// arch/arm64/kvm/hyp/nvhe/pkvm.c
static void pkvm_vcpu_init_traps(struct pkvm_hyp_vcpu *hyp_vcpu)
{
	hyp_vcpu->vcpu.arch.mdcr_el2 = 0;

	if (!pkvm_hyp_vcpu_is_protected(hyp_vcpu)) {
		/* Non-protected guest: take the host's HCR_EL2 as a hint */
		u64 hcr = READ_ONCE(hyp_vcpu->host_vcpu->arch.hcr_el2);
		hyp_vcpu->vcpu.arch.hcr_el2 = HCR_GUEST_FLAGS | hcr;
		return;
	}

	/* Protected guest: hyp decides the traps, host input is ignored */
	pvm_init_trap_regs(&hyp_vcpu->vcpu);
	pvm_init_traps_aa64pfr0(&hyp_vcpu->vcpu);   // hide/settle CPU feature regs
	pvm_init_traps_aa64pfr1(&hyp_vcpu->vcpu);
	pvm_init_traps_aa64dfr0(&hyp_vcpu->vcpu);   // trap debug so host can't observe guest
	pvm_init_traps_aa64mmfr0(&hyp_vcpu->vcpu);  // recall ID_AA64MMFR0_EL1 from the trap example!
	pvm_init_traps_aa64mmfr1(&hyp_vcpu->vcpu);
}

That last line is the same ID_AA64MMFR0_EL1 register from the trap-and-emulate example near the start of these notes; here pKVM installs the trap so a protected guest sees a sanitized view of the machine and the host can’t influence it.

6. Host ↔ guest page transfer: share vs. donate

When the host wants to give a guest a page (e.g. to populate guest RAM), the same hypercall behaves differently depending on whether the guest is protected, and this single if is the essence of the pKVM security guarantee:

// arch/arm64/kvm/hyp/nvhe/hyp-main.c
static void handle___pkvm_host_map_guest(struct kvm_cpu_context *host_ctxt)
{
	DECLARE_REG(u64, pfn, host_ctxt, 1);
	DECLARE_REG(u64, gfn, host_ctxt, 2);
	DECLARE_REG(u64, nr_pages, host_ctxt, 3);
	DECLARE_REG(enum kvm_pgtable_prot, prot, host_ctxt, 4);
	struct pkvm_hyp_vcpu *hyp_vcpu = pkvm_get_loaded_hyp_vcpu();

	/* Top up the per-vCPU page-table memcache from the host's donation */
	ret = pkvm_refill_memcache(hyp_vcpu);

	if (pkvm_hyp_vcpu_is_protected(hyp_vcpu))
		ret = __pkvm_host_donate_guest(hyp_vcpu, pfn, gfn, nr_pages);   // host LOSES access
	else
		ret = __pkvm_host_share_guest(hyp_vcpu, pfn, gfn, nr_pages, prot); // host keeps access

	cpu_reg(host_ctxt, 1) = ret;
}
  • Protected VMdonate: the page transitions HOST(OWNED) → GUEST(OWNED), and the host’s stage-2 entry becomes PKVM_NOPAGE. If the host later touches it, §3 kicks in and it faults.
  • Normal VMshare: the host keeps a SHARED_OWNED mapping, which is fine because a non-protected guest isn’t trying to hide anything from the host.

7. DMA and the SMMU under pKVM: FF-A

The SMMU and DMA section warned that a device doing DMA can bypass CPU stage-2 entirely. pKVM has to mediate this too: a device (or the secure world) must not DMA into a protected guest’s pages. Memory shared toward the secure world goes through FF-A (Firmware Framework for Arm-A), routed via the SMC arm of handle_trap:

// arch/arm64/kvm/hyp/nvhe/ffa.c
bool kvm_host_ffa_handler(struct kvm_cpu_context *ctxt, u32 func_id)
{
	switch (func_id) {
	case FFA_MEM_SHARE:
	case FFA_FN64_MEM_SHARE:
		do_ffa_mem_share(&res, ctxt);   // validates + records HOST -> FFA transition
		return true;
	case FFA_MEM_RECLAIM:
		do_ffa_mem_reclaim(&res, ctxt); // FFA -> HOST, page comes back
		return true;
	/* ... */
	}
}

Under the hood do_ffa_mem_share walks the descriptor the host provided and performs the ownership transition PKVM_ID_HOST → PKVM_ID_FFA for each page (via __pkvm_host_share_ffa), so EL2 always knows a page is out on loan to the secure world and won’t simultaneously hand it to a guest. This is the software counterpart to the SMMU translating device IPAs to real PAs; EL2 is the single arbiter that keeps CPU stage-2, guest ownership, and device/secure-world access mutually consistent.

http://events17.linuxfoundation.org/sites/events/files/slides/To%20EL2%20and%20Beyond_0.pdf https://www.cs.columbia.edu/~nieh/pubs/isca2016_armvirt.pdf https://docshare01.docshare.tips/files/26002/260020807.pdf https://connormcgarr.github.io/arm64-windows-internals-basics/ https://www.cs.columbia.edu/~cdall/pubs/asplos2014-dall.pdf https://kvmforum2020.sched.com/event/eE4Q https://lwn.net/Articles/836693/ https://developer.arm.com/documentation/102142/0100

Moe's VR blog

Security Research Enthusiast: Kernels, Hypervisors, or anything worth exploring.


2026-07-06