This is a write-up of one day's work getting a real, physical USB keyboard talking to Ouroboros, a from-scratch ARM64 microkernel, running on Parallels Desktop on Apple Silicon. It's kept separate from this project's own historical record because the bugs here aren't really Ouroboros-specific — they're the kind of thing anyone writing a bare-metal USB/xHCI driver and testing it against a real hypervisor is likely to run into, and there wasn't an existing write-up covering them. If you're building your own OS and your keyboard driver "sort of" works — enumerates fine, sends what look like correct commands, but you get garbage or silence back — some of this may save you a day of your own.
Seven real, confirmed bugs, each found by direct evidence — register dumps, decoded exception registers, byte-for-byte comparisons — rather than guessing. In order: a PCI Command register bit that looked like a platform quirk until it wasn't; real PCIe firmware panicking on a config-space write a software emulator tolerated silently; a physical PCI address landing outside a simplifying assumption baked into our own page tables; a whole class of USB request silently going nowhere; a working driver reading the wrong physical device; a driver that quietly dropped a keystroke the moment real preemptive multitasking gave it something new to be interrupted by; and — found not by this project's own testing at all, but by a person just trying to use the thing normally — a fixed iteration count standing in for a real timeout.
Why this was hard to see coming
Ouroboros develops primarily against QEMU, because it's fast to iterate against and fully scriptable. Real hardware testing — in this case, Parallels Desktop running the guest on Apple Silicon — happens periodically, by a human, and each round trip costs real wall-clock time: rebuild, re-image a disk, boot the VM, look at the screen, report back. That asymmetry matters: QEMU is where you can iterate fast and be wrong nine times before breakfast; real hardware is where you find out that some of your "confirmed" fixes were confirmed against the wrong thing.
Every one of the five bugs below is invisible on QEMU. Four of them worked correctly there on the first try — which is exactly the trap: a driver that passes its only automated-feeling test can still be wrong in ways that only real, spec-compliant (or spec-non-compliant) hardware will show you.
Aside — why there's no serial log for any of thisIf you're used to
dmesg-driving your bring-up, the biggest practical obstacle here isn't any single bug — it's that this guest has no working byte-stream console on Parallels at all. The only console that works post-boot-services is a raw framebuffer text console: write-only, and the only thing there is to look at. Every diagnostic technique below is shaped by that constraint — nothing printed during UEFI boot services is ever visible again once the framebuffer console clears the screen and takes over, so anything you want visible after a crash has to be re-printed through the post-boot console, not just logged once during discovery. More than one debugging round in this project was extended by a day purely because a useful line had already scrolled off-screen by the time a crash happened.
Bug 1 — the Command register bit that wasn't where we thought
Symptom: after discovering the xHCI controller's
PCI BAR and writing what we believed was "enable Memory Space + Bus
Master" to its PCI Command register, every register read through
that BAR came back as 0xffffffff — the
standard PCI "nothing responds here" pattern — on both QEMU and
real Parallels hardware.
The trap: this presents as a platform problem, not a code problem. On QEMU specifically, the controller's BAR really was completely unassigned by firmware, so we built and shipped a whole BAR-self-assignment routine before ever suspecting the enable bit itself was wrong. Wrong write width, wrong access granularity — both got tested and ruled out first, because they sounded more plausible than "you transcribed a bit position wrong."
How it was found: a diagnostic printing the Command register's value before and after our own write, re-printed through the post-boot console specifically. One real test produced:
PCI command register 0x0010 -> 0x0015
0x0010 = bit 4 set (an unrelated bit,
firmware's own default). 0x0015 =
0b10101 — bits 0, 2, and 4. Bit 2 is
Bus Master Enable, correctly set. But bit 0 is
I/O Space Enable — Memory Space Enable is bit
1. The constant in our driver:
// WRONG — this is I/O Space Enable, not Memory Space Enable
const CMD_MEMORY_SPACE: u16 = 1 << 0;
const CMD_BUS_MASTER: u16 = 1 << 2; // correct
An off-by-one, in the literal sense — bit 0 vs. bit 1 — sitting in a constant that had been reasoned about half a dozen times without anyone re-deriving it from the PCI spec's actual bit table. An xHCI controller has no I/O-space BAR to enable at all, so this bit was, from the device's perspective, meaningless — we were never enabling the one bit that would let a memory-mapped BAR respond to anything.
When a register write "succeeds" — no error, readback matches what you wrote — but has no observable effect, re-derive the bit positions you're relying on from the spec table directly, by hand, before looking anywhere else. A successful write of the wrong bit looks, from software, exactly like every other kind of "nothing happened."
Bug 2 — real firmware panics where emulation just shrugs
Symptom: an early version of the driver, on real Parallels hardware, didn't produce a kernel crash or exception report — the whole VM aborted, with a hypervisor-level crash dialog.
The crash report, from Parallels' own hypervisor log:
mon.abort.message = PANIC@11.28 UEFI-exception-ArmPciCpuIo2Dxe.dll
ArmPciCpuIo2Dxe.dll is the EDK2
firmware driver implementing low-level CPU I/O access
for PCI config space itself — underneath the higher-level
protocol our own driver calls into. A panic there happens during
UEFI boot services, before our kernel's own exception vectors even
exist, so there was no way for our code to catch or report it — the
VM just stops.
Root cause: a BAR-reassignment probe — the
classic "write 0xFFFFFFFF, read back
the size mask, write a real address" technique, needed because
QEMU's firmware build leaves this controller's BAR completely
unassigned. Textbook, and QEMU's own PCI emulation tolerates it
without complaint. Real PCIe firmware, apparently, does not —
whatever internal state that sequence disturbed on a real, in-use
device was enough to make firmware's own PCI layer panic outright.
Fix: stopped touching PCI config space with anything but reads, plus one narrow, conditional Command-register write. If a real BAR comes back genuinely unassigned on real hardware, the honest answer is "this driver can't safely fix that," not "let's try writing to it and see."
The self-assignment logic existed at all only to work around
QEMU's own firmware leaving an unclaimed device's BAR at
0 — a real, confirmed, build-specific
quirk, not representative of production firmware (Parallels'
real firmware had already assigned a sane address without any
help). If you build a workaround for one dev-loop's laziness,
gate it as narrowly as possible — it's exactly what triggered the
firmware panic above.
Bug 3 — a real BAR doesn't respect your simplifying assumptions
Symptom: even after the address-space bugs above were sorted, the very first register read through the discovered BAR took a genuine CPU exception.
ESR_EL1 = 0x96000004
EC (bits 31:26) = 0x25 Data Abort
DFSC (bits 5: 0) = 0x04 Translation fault, level 0
Not a permissions problem — a genuine nothing is mapped here fault, at the top-level table walk.
Root cause: our own page tables. This kernel's identity-map setup assumed all RAM and every device region it would ever need lived within the first 512GB of address space — one top-level page-table entry's worth. Reasonable at the time: RAM addresses are always low, and the one device region this project had ever hardcoded was too. A discovered PCI BAR is under no such obligation — this controller's BAR resolved to exactly 512GB on the QEMU dev loop, squarely outside that one entry.
Fix: generalized the identity-map builder to allocate additional top-level table entries on demand, for whichever address range a discovered device actually needs.
A value that's "always true" for every address you've picked yourself stops being true the moment you start reading addresses that hardware, or firmware, picked instead.
Bug 4 — an entire request class silently going nowhere
The deepest and most surprising finding of the day.
Symptom: once the controller was enumerating the
keyboard correctly, polling it via the standard USB HID mechanism —
repeated GET_REPORT requests to the
control endpoint — never returned anything resembling keyboard
data. It came back, variously, as:
-
This driver's own
GET_REPORTSetup packet, echoed straight back — confirmed by decoding the returned bytes against the exact request sent (bmRequestType,bRequest,wValue,wIndex,wLength, all present, in order — including a changedwLengthtracking exactly across two separate test builds). - In one test, a chunk of what decoded cleanly as a completely different descriptor — real, structured data, just not what was asked for and not a live report either.
Neither of those is "the device sent nothing." Both are "the response buffer contains something structured," which is a much more confusing failure than a clean timeout — it looks like success from a distance.
Ruling out the obvious explanations, each with direct evidence
- Matching the wrong completion event? Printed every transfer-completion event's own pointer field next to the address of the TRB we expected. They matched, every time. Not this.
- A buffer-size coincidence? Widened the requested length from 8 bytes to 64 across two separate real-hardware test rounds. The garbage tracked the new length exactly. Not a fixed-size aliasing bug — whatever's happening adapts to what we send, in real time.
-
Is the DMA target even written to? Filled the
destination buffer with a distinctive, otherwise-impossible byte
pattern (
0xEErepeated) immediately before ringing the transfer doorbell. The poison did not survive — the buffer was genuinely overwritten. The transfer mechanism itself was working correctly; the content being delivered simply wasn't real device data.
The test that actually settled it: a completely different,
standard request — GET_DESCRIPTOR(Device)
— issued right next to a failing GET_REPORT.
It came back perfectly:
[12, 01, 00, 02, 00, 00, 00, 40, 27, 06, 01, 00, 00, 00, 01, 04, 0b, 01]
^^ ^^ ^^^^^^^^
bLength=18 bDescriptorType=1 idVendor (a later test: 0x203a — Parallels' own real, registered vendor ID)
Unambiguously live, correct, real device data.
The conclusion: standard USB control requests reach the real device correctly through Parallels' USB passthrough. Class-specific HID requests — the two mechanisms this driver had been built around — do not get forwarded at all. A real gap in the passthrough implementation, not a bug in any driver talking to it — and easy to misdiagnose as "my request encoding must be subtly wrong," because the bytes we were sending were correct the entire time.
The fix: stop polling via
GET_REPORT entirely. Use the mechanism
every production USB HID driver actually uses at runtime: the
device's interrupt IN endpoint. Configured via
Configure Endpoint — a standard xHCI
command, not a USB class request — the host
controller then polls the device on its own schedule and delivers
new reports with zero further request/response round trips for
software to get wrong.
If a request "succeeds" but its content looks wrong in a structured way — not zeroed, not noise, but shaped like something else you recognize — consider whether the platform underneath you might be substituting or misrouting that specific request, before assuming your own encoding is wrong. And when a "simpler" mechanism and a "more standard" one are both available, the passthrough layer beneath you may only have been built and tested against the one real operating systems actually use.
Bug 5 — a perfectly working driver, reading the wrong device
Symptom: once the interrupt endpoint was fully armed, real live data started flowing — continuously, and changing. That was the tell: moving the mouse over the VM's window produced a steady stream of changing report data. It was the mouse, not the keyboard.
A real Parallels VM exposes at least a virtual mouse/tablet on the same xHCI controller as the keyboard, and this driver's port-scan logic — take the first port reporting a connected device — had no way to tell them apart. It grabbed whichever device enumerated on the lower-numbered port.
Fix: walk every connected port, and for each one,
fetch its Configuration descriptor and check the HID interface's
declared class and protocol —
bInterfaceClass = 3 (HID),
bInterfaceProtocol = 1 specifically for
Boot-Protocol Keyboard (2 is Mouse) —
before committing to configuring its interrupt endpoint.
In a virtualized, multi-device environment, "the first thing that responds" is never a substitute for checking you've found the specific thing you're looking for — even, especially, once everything else is finally working. This bug only became visible because the rest of the driver was finally right; it had been silently waiting behind the other four the whole time.
Bug 6 — a driver that assumed one report never contains two keystrokes
This one didn't surface the same day as the other five — it took a later, separate milestone (real ACPI-based interrupt-controller discovery, giving this platform working preemptive multitasking for the first time) to expose it, because that milestone changed something this driver had always quietly relied on: how often it actually gets to run.
Symptom: once preemptive task-switching started
working on real hardware, typed input occasionally arrived at the
shell one character short — uptime
typed correctly would sometimes show up as
uptme or uptie.
Not a hang, not a crash, not even consistent: it happened roughly
once every several commands, never reproduced identically twice —
and the driver's own debug log showed every expected HID report
arriving, including the one for the character that went
missing.
The trap: that last detail pointed at the wrong culprit. The obvious explanation was a hardware/timing race — this driver only ever keeps one interrupt-transfer buffer posted to the device at a time, re-arming it fresh after each read, and once the consuming task only runs in alternating time slices instead of continuously, a report arriving before that re-arm completes has less slack than it used to. That's a real, plausible failure mode for polled USB HID input in general — it just wasn't what was actually happening here.
Root cause, found by reading the code path the log line lived in rather than trusting the log line's implication: a single polled HID report can legitimately contain more than one newly-pressed keycode at once. USB HID's boot-protocol keyboard report format reports currently held keys, up to six at a time, not a queue of press/release events — so if a poll happens to land after two separate keys have both transitioned to pressed since the last poll (most likely when a poll gets skipped because this task was preempted between two hardware samples, but not exclusively), both show up together in the very next report read.
let previous = self.last_report;
self.last_report = buf; // recorded before scanning
console::println!("xhci: report {buf:02x?}");
for &keycode in &buf[2..8] {
if keycode == 0 || keycode < 4 { continue; }
if previous[2..8].contains(&keycode) { continue; }
if let Some(ascii) = keycode_to_ascii(keycode, shift) {
return Some(ascii); // returns on the FIRST match
}
}
self.last_report = buf runs — recording
the entire report as "already seen" — before the scan loop
that looks for newly-pressed keys, and the loop returns on the
first qualifying keycode it finds. If a report contains two new
keycodes, the first is correctly translated and returned; the
second is gone forever, not just delayed — the very next poll
compares against last_report, which
already includes it, so it can never look "new" again. The debug
print above the loop runs unconditionally for every accepted
report, whether the scan below it manages to return all of that
report's new keys or only some of them — which is exactly why the
log showed the "missing" character's own report all along.
Fix: stop discarding everything past the first match. Drain every qualifying keycode from a report into a small fixed-size pending buffer (5 slots — a report holds at most 6 simultaneous keycodes, and the first one found is always returned immediately rather than queued, so at most 5 can ever need to wait), and drain that buffer on subsequent polls before ever touching the event ring again.
fn poll_key(&mut self) -> Option<u8> {
if self.pending_len > 0 {
let ascii = self.pending[0];
self.pending.copy_within(1..self.pending_len, 0);
self.pending_len -= 1;
return Some(ascii);
}
// ... unchanged: pop an event, read the report, re-arm, edge-detect ...
let mut result = None;
for &keycode in &buf[2..8] {
if keycode == 0 || keycode < 4 { continue; }
if previous[2..8].contains(&keycode) { continue; }
let Some(ascii) = keycode_to_ascii(keycode, shift) else { continue };
if result.is_none() {
result = Some(ascii);
} else {
self.pending[self.pending_len] = ascii;
self.pending_len += 1;
}
}
result
}
Confirmed fixed on real hardware, not just reasoned
through: ten consecutive uptime
invocations typed back-to-back, all recognized correctly with zero
drops — a clear, direct contrast against the intermittent failures
observed before the fix, on the exact platform and exact input
pattern that had triggered it.
A debug log line that runs unconditionally, positioned before the logic that determines the actual outcome, can make a bug look like it's in a completely different layer of the system than it actually is. "The log shows the data arrived" and "the log shows the data was correctly handled" are different claims. It's also a reminder that a platform change elsewhere in the system — here, real preemption finally working — can expose a latent bug in completely unrelated code that had simply never been exercised under those conditions before.
Bug 7 — an iteration count standing in for a timeout, found by a user, not a script
The first six bugs in this document were all found through deliberate, scripted, one-variable-at-a-time testing. This one was found by a person just trying to use the thing normally — a useful reminder that scripted testing, however thorough, only ever exercises the scenarios someone thought to script.
Symptom: booting the real disk image normally in
Parallels — a manually-launched VM, with a live window actually
being watched, not this project's own headless scripted test loop
— occasionally produced
xhci: keyboard not available (Command ring:
timed out waiting for a completion event). The keyboard
driver failed outright, something none of this project's own
real-hardware testing had ever hit, across many prior runs.
The key clue was asking for a second attempt and getting a different failure point. The first report failed almost immediately in port setup — most likely one of the very first command-ring operations. A second attempt, on request, got much further: through slot addressing, configuration, and interrupt-endpoint descriptor discovery, before failing on what's almost certainly the final command that arms the interrupt endpoint. Two different commands timing out on two different boots is a strong signal that the wait mechanism itself is the problem, not any one command's own logic — the same shape of reasoning that turned "one bus fault at one address" into "the entire fixed-address convention is unsafe" in the companion bring-up postmortem, once a second, differently-addressed fault showed up there.
Root cause: every busy-wait in this driver — a command completing, a transfer completing, a port reporting a connection — was bounded by a fixed iteration count, on the reasoning (true when it was written) that no interrupts or timer are available yet at this point in boot. The gap: a fixed iteration count is only a valid stand-in for real elapsed time if the host never preempts the guest's virtual CPU for any real duration while it spins. A real hypervisor doesn't guarantee that. The one concrete difference between this project's own testing (headless, no VM window ever rendered) and the user's manual run (a real, live window, being watched) is exactly the kind of thing that competes for real host CPU/GPU time — stalling the guest's virtual CPU mid-poll for a real, unpredictable duration costs it zero "iterations" while real wall-clock time that should have counted toward the timeout quietly passes.
Fix: replace the fixed iteration count with a genuine wall-clock deadline, using the ARM generic timer's free-running physical counter — a pure system-register read needing no interrupt controller and no timer interrupt at all, the identical "safe on any core, no dependencies" property this project's own timer-tick driver already relied on for a different reason.
fn poll_deadline() -> u64 {
now_ticks() + frequency_hz() / 1000 * POLL_TIMEOUT_MS
}
fn wait_command_completion(&mut self, cmd_ptr: u64) -> Result<Trb, Error> {
let deadline = poll_deadline();
while now_ticks() < deadline {
// ... unchanged: pop an event, check its type, check it matches ...
}
Err(Error::CommandTimeout)
}
Confirmed fixed by the user directly, on the exact real-world scenario that had originally failed — not a script standing in for it. Also regression-tested on the software emulator this project otherwise develops against, and through this project's own scripted real-hardware test loop — neither of which had ever reproduced the original bug, but both confirm the fix doesn't cost anything on the already-working path.
"No interrupts available yet, so this has to be iteration-bounded" was a true, reasonable constraint when it was first written — and stopped being the only option the moment a free-running hardware counter, needing no interrupt controller at all, was already sitting right there for a different subsystem's use. Worth checking, any time a past design constraint gets cited as a reason something can't be improved, whether the thing that originally motivated it is still actually true. And: a bug only a human, watching a real, live-rendered VM window, could ever trigger — never a script driving the identical VM headlessly — is a real, useful data point about where a bug lives, not just an unlucky report.
Techniques that generalized well
- Poison your DMA buffers. Fill a transfer's destination with a fixed, distinctive, otherwise-impossible pattern before triggering it. Whether — and how much of — the poison survives tells you directly whether a write happened at all.
- Widen a request to break a suspicious coincidence. If a buggy result's shape matches some other value in your system, deliberately change that value. If the bug's shape changes with it, you've confirmed a real dependency, not ruled one out.
- Reach for the most boring request you can, as a control. When one request type misbehaves, test the most basic, universal request in the same family right next to it.
- Decode raw exception registers by hand, every time. Exception class, fault status, faulting address — the difference between "the VM crashed" and "a Data Abort at exactly this address, for exactly this documented reason."
- Route diagnostics through whatever survives a crash, not just whatever's convenient to log through at the time.
- Fix one variable per real-hardware round trip, and predict the outcome before you spend it. Every round trip here targeted one falsifiable hypothesis — which is what made five real bugs findable in a single day.
Where this ended up
A real, physical USB keyboard, on real Parallels-on-Apple-Silicon hardware, typing into a shell running on a from-scratch kernel with no prior USB support at all — full round trip confirmed: individual keystrokes, backspace, Enter submitting a real command line, with every keystroke landing correctly even under real preemptive multitasking, and the driver itself now reliably reaching a working keyboard on a real, manually-launched VM, not just under scripted testing. Getting there took a from-scratch xHCI driver and seven real, independently-confirmed hardware bugs — five found in the initial one-day push, none of them visible on the software emulator this project otherwise develops against day to day; a sixth found weeks later, exposed only once a separate milestone gave this platform real preemption for the first time; and a seventh found not by this project's own testing at all, but by a person just trying to use the thing normally.
If you're doing the equivalent work for your own kernel: budget for the fact that "works on QEMU" and "works on a real hypervisor" are genuinely different claims, that a firmware or passthrough layer's rough edges won't announce themselves as errors, and that the most confusing failures are the ones that come back looking like something, not nothing. And this driver's story didn't end with the keyboard: part four covers the day it grew a second device — a USB stick that became this platform's first and only working disk.