Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ jobs:
# an allocation advisory. Neither is a correctness failure.
run: staticcheck -checks='SA*,-SA1019,-SA6002' ./...

- name: Enable KVM for native x86 tests
if: matrix.name == 'linux-amd64'
run: |
sudo chown "$(id -u):$(id -g)" /dev/kvm
sudo chmod 660 /dev/kvm
test -r /dev/kvm && test -w /dev/kvm

- name: Run tests
run: go test -short ./... -count=1 -timeout 30m

Expand Down
51 changes: 51 additions & 0 deletions hypervisor/x86.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package hypervisor

import (
"context"
"j5.nz/cc/hypervisor/x86state"
)

// X86Exit exposes architectural exits. IO.Data is borrowed until the next Run;
// fill it for input instructions. CompleteMMIORead completes MMIO reads.
type X86Exit struct {
Reason uint32
Port uint16
Size uint8
Count uint32
Write bool
Data []byte
Address uint64
}

// RAMRegion maps a page-aligned range of a shared backing allocation.
// Unmapped ranges remain device apertures and generate MMIO exits.
type RAMRegion struct{ Address, Offset, Size uint64 }

const (
X86ExitIO uint32 = 2
X86ExitDebug uint32 = 4
X86ExitHLT uint32 = 5
X86ExitMMIO uint32 = 6
X86ExitShutdown uint32 = 8
)

// X86 owns its RAM and one CPU. All methods except Cancel must be serialized.
// The PC interrupt controllers and interval timer are provided by the backend.
// Firmware, disk and display device semantics belong to the caller.
type X86 interface {
MapRAM(base, size uint64) ([]byte, error)
MapRAMRegions(size uint64, regions []RAMRegion) ([]byte, error)
Registers() (x86state.Registers, error)
SystemRegisters() (x86state.SystemRegisters, error)
SetRegisters(x86state.Registers) error
SetSystemRegisters(x86state.SystemRegisters) error
Run(context.Context) (X86Exit, error)
// CompleteIO commits a pending IO/MMIO operation without executing the
// next instruction. Call before inspecting or changing CPU state after IO.
CompleteIO() error
CompleteMMIORead(value uint64, size uint32)
SetIRQ(line uint32, level bool) error
InterruptState() (map[string]uint64, error)
Cancel() error
Close() error
}
135 changes: 135 additions & 0 deletions hypervisor/x86_linux_amd64.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
//go:build linux && amd64

package hypervisor

import (
"context"
"errors"
"fmt"
"runtime"

"golang.org/x/sys/unix"
"j5.nz/cc/internal/hv/kvm"
)

type nativeX86 struct {
*kvm.VM
slot uint32
allocations [][]byte
mappings []RAMRegion
}

func (n *nativeX86) MapRAMRegions(size uint64, regions []RAMRegion) ([]byte, error) {
if size == 0 || size > uint64(^uint(0)>>1) || size%4096 != 0 || len(regions) == 0 || n.slot != 0 || len(n.allocations) != 0 {
return nil, fmt.Errorf("invalid RAM layout or RAM already mapped")
}
for i, r := range regions {
if r.Size == 0 || r.Offset > size || r.Size > size-r.Offset || r.Address > ^uint64(0)-r.Size || (r.Address|r.Offset|r.Size)%4096 != 0 {
return nil, fmt.Errorf("invalid RAM region %d", i)
}
for _, prior := range regions[:i] {
if r.Address < prior.Address+prior.Size && prior.Address < r.Address+r.Size {
return nil, fmt.Errorf("overlapping RAM regions")
}
}
}
ram, err := unix.Mmap(-1, 0, int(size), unix.PROT_READ|unix.PROT_WRITE, unix.MAP_ANONYMOUS|unix.MAP_PRIVATE)
if err != nil {
return nil, err
}
n.allocations = append(n.allocations, ram)
for _, r := range regions {
if err = n.MapSharedMemory(ram[r.Offset:r.Offset+r.Size], r.Address); err != nil {
return nil, err
}
}
return ram, nil
}

func (n *nativeX86) Close() error {
err := n.VM.Close()
for _, ram := range n.allocations {
err = errors.Join(err, unix.Munmap(ram))
}
n.allocations = nil
return err
}

func NewX86(ctx context.Context) (X86, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
vm, err := kvm.NewVM()
if err != nil {
return nil, err
}
return &nativeX86{VM: vm}, nil
}
func (n *nativeX86) MapRAM(base, size uint64) ([]byte, error) {
if len(n.allocations) != 0 {
return nil, fmt.Errorf("RAM layout already mapped")
}
if size == 0 || size > uint64(^uint(0)>>1) || base > ^uint64(0)-size || (base|size)%4096 != 0 {
return nil, fmt.Errorf("invalid page-aligned RAM mapping")
}
for _, r := range n.mappings {
if base < r.Address+r.Size && r.Address < base+size {
return nil, fmt.Errorf("RAM mapping overlaps existing RAM")
}
}
ram, err := n.MapAnonymousMemorySlot(n.slot, size, base)
if err == nil {
n.slot++
n.mappings = append(n.mappings, RAMRegion{Address: base, Size: size})
}
return ram, err
}
func (n *nativeX86) Cancel() error { n.RequestImmediateExit(); return nil }
func (n *nativeX86) Run(ctx context.Context) (X86Exit, error) {
if err := ctx.Err(); err != nil {
return X86Exit{}, err
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
n.SetVCPUTID(0, unix.Gettid())
defer n.SetVCPUTID(0, 0)
// Most runs finish at an IO/MMIO exit long before cancellation. Starting
// and joining a goroutine for every VGA access forces scheduler handoffs
// in the hottest device path. AfterFunc runs a goroutine only if cancelled.
joined := make(chan struct{})
stop := context.AfterFunc(ctx, func() {
n.RequestImmediateExit()
close(joined)
})
defer func() {
if !stop() {
// A late callback must not interrupt a subsequent run or race Close.
<-joined
}
}()
if err := ctx.Err(); err != nil {
return X86Exit{}, err
}
var ex kvm.Exit
err := n.RunVCPUInterruptible(0, &ex)
if errors.Is(err, unix.EINTR) {
return X86Exit{}, ctx.Err()
}
if err != nil {
return X86Exit{}, err
}
ret := X86Exit{Reason: uint32(ex.Reason)}
if ex.Reason == kvm.ExitUnknown {
return X86Exit{}, fmt.Errorf("unknown KVM hardware exit")
}
if ex.Reason == kvm.ExitIO {
ret.Port, ret.Size, ret.Count, ret.Write, ret.Data = ex.IO.Port, ex.IO.Size, ex.IO.Count, ex.IO.Write, ex.IO.Data
} else if ex.Reason == kvm.ExitMMIO {
if ex.MMIO.Len == 0 || ex.MMIO.Len > 8 {
return X86Exit{}, fmt.Errorf("invalid KVM MMIO width %d", ex.MMIO.Len)
}
ret.Address, ret.Size, ret.Write = ex.MMIO.Addr, uint8(ex.MMIO.Len), ex.MMIO.Write
ret.Data = append([]byte(nil), ex.MMIO.Data[:ex.MMIO.Len]...)
}
return ret, nil
}
159 changes: 159 additions & 0 deletions hypervisor/x86_linux_amd64_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
//go:build linux && amd64

package hypervisor

import (
"context"
"errors"
"os"
"testing"
"time"

"j5.nz/cc/hypervisor/x86state"
)

func TestX86RealModeInterrupt(t *testing.T) {
if _, err := os.Stat("/dev/kvm"); err != nil {
t.Skip("KVM unavailable")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
cpu, err := NewX86(ctx)
if err != nil {
t.Fatal(err)
}
defer cpu.Close()
ram, err := cpu.MapRAM(0, 2<<20)
if err != nil {
t.Fatal(err)
}
// INT 13h calls a ROM stub which traps to Go, then IRET returns to
// the boot sector. The sector emits a second OUT to prove the return.
copy(ram[0x7c00:], []byte{0xcd, 0x13, 0xba, 0xf1, 0, 0xee, 0xeb, 0xfe})
copy(ram[0xf0100:], []byte{0x50, 0xba, 0xf0, 0, 0xee, 0x58, 0xcf})
ram[0x4c], ram[0x4d], ram[0x4e], ram[0x4f] = 0, 1, 0, 0xf0
s, err := cpu.SystemRegisters()
if err != nil {
t.Fatal(err)
}
seg := x86state.Segment{Limit: 0xffff, Present: 1, S: 1, Type: 3}
s.Cs, s.Ds, s.Es, s.Ss, s.Fs, s.Gs = seg, seg, seg, seg, seg, seg
s.Cs.Type = 11
s.Cr0 = 0x10
s.Cr3 = 0
s.Cr4 = 0
s.Efer = 0
s.Idt = x86state.DescriptorTable{Limit: 0x3ff}
if err = cpu.SetSystemRegisters(s); err != nil {
t.Fatal(err)
}
if err = cpu.SetRegisters(x86state.Registers{Rip: 0x7c00, Rsp: 0x7c00, Rflags: 2, Rax: 0x1234}); err != nil {
t.Fatal(err)
}
for _, port := range []uint16{0xf0, 0xf1} {
for {
ex, err := cpu.Run(ctx)
if err != nil {
t.Fatal(err)
}
if ex.Reason == 0 {
continue
}
if ex.Reason != X86ExitIO || ex.Port != port || !ex.Write || len(ex.Data) != 1 || ex.Data[0] != 0x34 {
t.Fatalf("unexpected exit: %+v", ex)
}
if err := cpu.CompleteIO(); err != nil {
t.Fatal(err)
}
break
}
}
r, err := cpu.Registers()
if err != nil {
t.Fatal(err)
}
if r.Rsp != 0x7c00 {
t.Fatalf("IRET did not restore stack: %#x", r.Rsp)
}
// An execution breakpoint must stop before the instruction, without
// changing guest code, and disabling it must allow normal execution.
debug := cpu.(interface{ SetBreakpoint(uint64) error })
r.Rip = 0x7c02
if err := cpu.SetRegisters(r); err != nil {
t.Fatal(err)
}
if err := debug.SetBreakpoint(r.Rip); err != nil {
t.Fatal(err)
}
for {
ex, err := cpu.Run(ctx)
if err != nil {
t.Fatal(err)
}
if ex.Reason == 0 {
continue
}
if ex.Reason != X86ExitDebug {
t.Fatalf("breakpoint: %+v", ex)
}
break
}
stopped, err := cpu.Registers()
if err != nil || stopped.Rip != r.Rip || ram[0x7c02] != 0xba {
t.Fatalf("breakpoint changed execution/code: %+v %v", stopped, err)
}
if err := debug.SetBreakpoint(0); err != nil {
t.Fatal(err)
}
for {
ex, err := cpu.Run(ctx)
if err != nil {
t.Fatal(err)
}
if ex.Reason == 0 {
continue
}
if ex.Reason != X86ExitIO || ex.Port != 0xf1 {
t.Fatalf("resume breakpoint: %+v", ex)
}
if err := cpu.CompleteIO(); err != nil {
t.Fatal(err)
}
break
}
// The next instruction spins. Cancellation must interrupt KVM, and its
// callback must finish before a subsequent run starts using the same CPU.
for i := 0; i < 20; i++ {
deadline, cancel := context.WithTimeout(ctx, 2*time.Millisecond)
for {
_, err = cpu.Run(deadline)
if err != nil {
break
}
}
cancel()
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("cancel run: %v", err)
}
r.Rip = 0x7c02 // mov dx,f1; out dx,al; spin
if err = cpu.SetRegisters(r); err != nil {
t.Fatal(err)
}
for {
ex, err := cpu.Run(ctx)
if err != nil {
t.Fatal(err)
}
if ex.Reason == 0 {
continue
}
if ex.Reason != X86ExitIO || ex.Port != 0xf1 {
t.Fatalf("exit after cancellation: %+v", ex)
}
if err := cpu.CompleteIO(); err != nil {
t.Fatal(err)
}
break
}
}
}
12 changes: 12 additions & 0 deletions hypervisor/x86_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//go:build !linux || !amd64

package hypervisor

import (
"context"
"fmt"
)

func NewX86(context.Context) (X86, error) {
return nil, fmt.Errorf("cc x86 native execution requires Linux/amd64 KVM")
}
Loading
Loading