Kheng Meng profile pic

Yeo Kheng Meng

Maker, Coder, Private Pilot, Retrocomputing Enthusiast

14 minutes read

Most of us take the USB ports on our computers for granted. We plug in devices and expect them to work automatically with the operating system. However, what if this wasn’t the case with vintage operating systems?

I present to you xHCI98, a driver that gives Windows 98 SE and Windows 2000 SP4 working USB on machines whose only USB controller is xHCI.

Windows 98 Device Manager showing xHCI Controller initialised and ready to accept USB devices on an operating system that otherwise cannot support it.

Everything is open-sourced here if you want to jump right in to use it: https://github.com/yeokm1/xhci98

Demos

Demo video here showing me hotplugging many different types of USB devices to my 2020 ThinkPad P14s Gen 1. Hotplugging shows that the driver is handling this and not the BIOS.

ATTO Disk Benchmark on a USB 3.0 flash drive gives around 18 MB/s read and write from 32 KB transfers upward. That is USB 2.0 High-Speed territory, which is all this driver supports and I’ll explain why below.

Background

The USB host controller on most modern PCs today is based on the Extensible Host Controller Interface (xHCI) USB 3.0 standard. It was first publicly finalised by Intel in 2010 and is now the de-facto standard superseding prior USB standards. Updates to the standard have been provided over the years with the latest now at 1.2c.

Given its relatively recent introduction, only modern operating systems like Windows 8 onwards natively support it. Supporting older machines will require the chipset vendors to provide the driver stack which is the case up to Windows XP. What if we want to go back further?

Motivation

Earlier this year, I set up Windows 98 SE on my relatively modern 2020 ThinkPad P14s Gen 1.

More details of this machine here: https://github.com/yeokm1/retro-configs/tree/master/laptops/thinkpad-p14sg1

It first came to my attention then that Windows 98 and many legacy operating systems do not support the xHCI Controller. Even if the USB device or ports are USB 2.0 based, it still will not work as those ports are controlled by xHCI.

I came across this idea of using a USB 2.0 PCI card to bypass this issue from Omores’ YouTube video xHCI vs. EHCI: The Secret to Getting USB 2.0 back on Windows 9x and 2K+.

Since a laptop does not have a PCI slot, I used a TH3P4G3 Thunderbolt eGPU dock to connect a PCI USB 2.0 EHCI card, giving this oddball chain of old and new parts: Thunderbolt -> eGPU dock -> PCIe-PCI adapter -> VIA USB 2.0 PCI card -> USB adapters.

Windows 98 SE actually does not natively support USB 2.0 but a driver project called NUSB33E backported Windows 2000 drivers to enable this to work.

While that literal tower of adapters worked, it was an eyesore and not very elegant in my opinion. So I wanted to see if I could build a native xHCI driver for Windows 98. Especially with AI assistance today, this should be possible right?

Why xHCI is hard

I looked around existing implementations and found that most xHCI implementations are in major OSes like Windows 8 onwards, modern Linux and macOS written by a major company or a large project. Other projects mostly stop at the USB 2.0 standards of UHCI, OHCI and EHCI.

During the course of this project I found out the hard way why this is so.

The specification is huge and the controller is not simple. The xHCI 1.2 specification runs to over 600 pages.

The driver has to set up a whole family of data structures before the controller does anything. Plenty of concepts to grasp like the command/event/transfer rings, Slot/Endpoint/Input Contexts and Doorbell etc. Every one of those data structures has exact alignment rules and a mistake just causes a hang.

I believe the vendors who shipped xHCI drivers for Windows XP and 7 did so because those OSes still had a market. For Windows 98 there was never a commercial reason.

But with such advanced AI today which is able to crunch through the entire specification and reference existing open-source code, things are definitely different compared to months or years ago.

Why only USB 2.0 on a USB 3.0 controller

My first decision was to not write the whole USB stack.

Windows 2000’s USB stack (and NUSB’s back-port of it to Windows 98) is split into two layers.

  1. usbport.sys is Microsoft’s port driver that owns the root hub, enumeration, USB Request Block (URB) parsing and everything the class drivers talk to.

  2. Underneath it sit small miniport drivers, one per controller type, that only knows how to talk to their hardware: usbuhci.sys, usbohci.sys and usbehci.sys.

xHCI98 is just another miniport. xhci98.sys plugs in underneath usbport.sys exactly the way usbehci.sys does and only owns the xHCI hardware.

I got AI to draw up this diagram:

Diagram of the Windows 98 SE and 2000 SP4 USB stack showing xhci98.sys as a usbport.sys miniport alongside usbuhci, usbohci and usbehci, driving the xHCI controller's USB 2.0 ports

Everything in green and blue on the top half is Microsoft’s Windows 2000 stack (NUSB’s back-port on Windows 98), orange is this project, purple is the USB 2.0 ports the driver manages, and the greyed-out miniports and controllers are the legacy path that xHCI-only machines no longer have.

The catch is that usbport.sys is a USB 2.0-era design. It has no concept of SuperSpeed and neither does anything above it. Doing USB 3.0 properly would mean replacing the entire host controller driver (HCD) stack on both operating systems and I doubt many Windows 98 class machines would benefit from that speed anyway.

Fortunately every USB 3.0 connector also carries the USB 2.0 wires and xHCI exposes them as a separate logical port per connector. The driver manages those USB 2.0 ports and leaves the USB 3.0 ports unpowered, so a SuperSpeed device falls back onto the USB 2.0 pins of the same connector and runs at High-Speed. That is why the flash drive above benchmarks at USB 2.0 speed.

Although my project primarily targets Windows 98, the USB stacks of Windows 98 (via NUSB) and Windows 2000 are similar enough that this driver supports both OSes.

XHCIQUAL: Qualify the machine before touching Windows

Before the driver existed, I wanted a way to know if a machine’s xHCI Controller can even work with Windows 98 before I waste effort on a driver which may not even succeed.

One concept that stood out in the xHCI specification is its preference for Message Signaled Interrupts (MSI/MSI-X) over legacy line-based interrupts (INTx) with INTx kept only as a fallback.

I got AI to draw this diagram to explain the differences:

Comparison of MSI/MSI-X and legacy INTx# interrupt delivery: MSI needs an OS APIC interrupt subsystem that Windows 98 lacks and Windows only gained in Vista, so xhci98.sys uses the controller's INTx# pin through the 8259 PIC, the VMM and usbport.sys's ISR and DPC

MSI is a memory write to the CPU’s local Advanced Programmable Interrupt Controller (APIC) whose address and vector the OS must allocate and program into the device.

Neither Windows 98 nor Windows 2000 has this; MSI support was only integrated in Windows Vista onwards. The driver therefore has to rely solely on the controller’s fallback INTx# pin.

To test if my machine has implemented the interrupt to determine if this project is even possible, I worked on a tiny qualification program called XHCIQUAL.EXE to verify this capability and more. It is a DOS tool built with Open Watcom 2.0 and the DOS/32A extender.

Running it with no arguments gives a read-only one-line answer per controller.

XHCIQUAL full run, start of output XHCIQUAL full run, end of output with the verdict

Running it with the xhci argument exercises the controller more.

Video of the tool in action.

Referencing existing code

The usbport.sys miniport interface was never documented by Microsoft. There is no header, no import library and no sample in the Windows 2000 DDK.

Therefore I looked into open source implementations where I referred to ReactOS usbport.

With AI’s help I got to port over the references to this project but how would I know if those worked?

I asked AI to use those function interfaces to create a spike solution first. A stub xhci98.sys that registers with usbport.sys on both targets proves the lifecycle callbacks arrive without corrupting anything.

I also referred to Linux xHCI driver as the reference and got the idea of a command watchdog from it, which made me look further at the xHCI specification, which says software is responsible for all command timeouts.

It says the timeout should be 5 seconds before asserting Chip Hardware Reset (HCRST) to reset the Controller which I had initially missed.

Architecture and flow

With the help of AI again, here is the architecture diagram of the driver as well as the flow:

Internal architecture of xhci98.sys: usbport.sys callbacks enter through xhci_dispatch, xhci_rh, xhci_slot and xhci_xfer; the init, command, event and topology engines sit below; a pure host-tested core of ring, context, capability, port and memory code feeds xhci_pci.c, the only file that touches the xHCI registers and DMA common buffer

Here is one device plug-in going through the driver, to show how the files in the diagram work together. The miniport never tells usbport.sys “a device arrived”; it only says “something changed, come and look”, and usbport asks the questions.

  1. The device pulls the data lines and the controller sets the connect and connect-change bits in that port’s PORTSC register, writes a Port Status Change Event onto the event ring and asserts INTx#.
  2. xhci_evt.c’s ISR confirms the interrupt is ours and the DPC drains the event ring, routing the port event to xhci_rh.c.
  3. xhci_rh.c reads PORTSC, records it in its port shadow, and calls the one upward notification the registration packet offers, UsbPortInvalidateRootHub. That call carries no detail, just “look again”.
  4. usbport scans the root hub: RH_GetHubStatus, then RH_GetPortStatus for every port. For the new port the miniport answers with connect and connect-change set. usbport owns the root hub PDO and presents these answers as a hub’s status-change endpoint, so usbhub.sys above sees exactly what it would see from a physical hub.
  5. usbhub.sys acknowledges the change and asks for a port reset. usbport turns that into RH_SetFeaturePortReset. xhci_rh.c writes PORTSC.PR, waits for the Port Reset Change event and reports the port’s speed. usbport asks the miniport to create a device.
  6. xhci_slot.c decides an Enable Slot command is needed and hands it to xhci_cmd.c, which checks nothing else is in flight and arms the 5-second watchdog.
  7. xhci_ring.c encodes the command as a Transfer Request Block (TRB) and writes it at the enqueue position of the command ring, a circular buffer in the DMA common buffer. Each TRB carries a cycle bit that the driver flips on every lap around the ring, which is how the controller tells fresh entries from stale ones.
  8. xhci_pci.c writes to doorbell register 0, the controller’s own doorbell, which means “the command ring has work”.
  9. The controller fetches the TRB over DMA, executes it, allocates a slot and writes a Command Completion Event TRB into the event ring, which only the controller writes and only the driver reads. The event carries a completion code, the new slot ID and the address of the command TRB it is answering. The controller asserts INTx# again.
  10. xhci_evt.c’s DPC walks the event ring using the same cycle-bit trick in reverse (the controller flips the bit and the driver checks it), consuming events until it reaches one whose bit is stale. Each event is routed by type: transfer events go to xhci_xfer.c, port changes to xhci_rh.c, and this command completion goes to xhci_cmd.c.
  11. xhci_cmd.c matches the command TRB address in the event against the one command it has outstanding, cancels the watchdog and returns the slot ID to xhci_slot.c, which moves on to the next command in the chain, Address Device, and after that Configure Endpoint.
  12. Finally the DPC writes the Event Ring Dequeue Pointer register to tell the controller how far it has read, freeing those entries for reuse.

Every command and data transfer follows this flow. The driver writes into a ring it owns, rings a doorbell and waits for the answer on the event ring. Transfer rings work identically, one per endpoint, with a doorbell per device slot.

The driver is C89 built with MSVC 6.0 and the Windows 2000 DDK, a setup I borrowed from the WDMHDA project.

Test targets

Almost all development happened in QEMU with its qemu-xhci device. Three VMs:

  1. Windows 98 SE guest with NUSB
  2. Windows 2000 SP4 guest
  3. Windows 2000 SP4 guest with the multiprocessor HAL on two vCPUs to test if there are any race conditions associated with multiple CPUs.

The driver is built in three flavours from the same source to assist in testing.

  1. release: Typical driver to use.
  2. debug: A checked build that keeps frame pointers and carries the in-memory log ring and counters for additional diagnosis. Built separate to mitigate security loopholes on Windows 2000.
  3. qemu: QEMU-only and never published. It is the debug build plus a verbose per-line trace channel that writes to QEMU’s debug console on I/O port 0xE9.

There is also a fully automated device matrix. One PowerShell command boots each guest, plugs and unplugs every USB device model QEMU can emulate through the monitor socket, reads counters out of the running driver and writes a pass/fail report per device per target.

The real hardware is two Intel xHCI-only ThinkPads, both running Windows 98 SE:

Machine Controller
ThinkPad E460 (2016) Skylake, Sunrise Point-LP. xHCI 1.0, 18 ports (12 USB 2.0 managed, 6 USB 3.0 left unpowered)
ThinkPad P14s Gen 1 (2020) Comet Lake PCH-LP. xHCI 1.1, 18 ports (same split)

I chose both machines as they represent what I believe to be the extreme ends of who will find this project useful.

The 2016 Thinkpad E460 uses the Intel Sunrise Point 100-series Platform Controller Hub (PCH) chipset paired with Intel’s 6th-generation Skylake CPUs. Starting with this chipset, Intel removed physical EHCI USB 2.0 controllers entirely, routing all USB 2.0, 3.0, and 3.1 ports through a single xHCI controller.

The 2020 Thinkad P14s Gen 1 is amongst the last laptops that officially support UEFI-CSM which allows legacy BIOS-based operating systems like Windows 98 to be booted.

It’s a pretty narrow band of machines that will find this useful but my machines happen to fall into this band.

External test USB devices

I cobbled together a wide array of USB test devices in my possession to exercise the driver.

Speed Type Device
High Hub Terminus 7-port hub, multi-TT
High Hub Terminus 4-port hub, single-TT
High Hub Genesys 7-port hub (two cascaded chips)
Low HID Logitech USB Optical Mouse
Low HID Microsoft Wired Keyboard 600 (composite)
High Mass storage SanDisk U3 Titanium flash drive
SuperSpeed Mass storage MSSU10-128GSR and SanDisk 3.2Gen1 USB 3.0 flash drives
SuperSpeed UAS (falls back to Mass storage) StoreJet Transcend USB-to-SATA bridge (ASMedia)
High Ethernet ASIX AX88772A USB Ethernet
Full Audio Sound Blaster Play! 2 (UAC 1.0)
High Audio Sound Blaster X4 (UAC 2.0). Enumerates, but neither Windows 98 nor 2000 has a driver for it

USB 3.0 SuperSpeed devices will fall back automatically to High-Speed on this system.

How it was built with AI

xHCI98 was built with heavy AI assistance using these tools:

  • Claude Fable 5 for planning
  • Claude Opus 5 for coding
  • Codex GPT 5.6 Sol for reviewing

I did it in my spare time over a 2-month period.

Just like my Swift on Apple II project, without AI, this project would not have been feasible for me as a hobby project. A WDM host controller driver against an undocumented ABI, on a 28-year-old toolchain, for two operating systems is not a two-month side project by hand. I would estimate years if I ever finished it.

The documentation as the AI’s memory

The workflow was similar to SwiftII.

I planned the project as 17 phases, 0 through 16, with AI filling in the implementations as needed.

  1. XHCIQUAL, the DOS hardware qualifier
  2. Development environment
  3. Test environments: Win98 SE, Windows 2000 SP4, QEMU/WHPX migration, Windows 2000 SMP stress
  4. Miniport registration spike, the go/no-go gate
  5. Controller initialisation
  6. Root hub
  7. Device enumeration
  8. Interrupt transfers and HID, then external hub topology
  9. Bulk: mass storage and Ethernet
  10. Isochronous and USB audio
  11. Automated VM device matrix
  12. Power, packaging and stress
  13. Host- and guest-side decisions
  14. Final bare-metal validation
  15. The 1.0.0.0 release
  16. Specification revision 1.2c
  17. Unattended post-release run

Around the phases sit 9 design documents. Every session starts by reading the roadmap and whatever design record is relevant. The AI forgets everything when its session ends, so the documentation is how the next session inherits the judgment of the last one instead of re-making the same mistake.

Conclusion

Modern hardware running legacy operating systems has been a small obsession of mine for a while now and USB was the one thing that made those Windows 98 machines unusable in practice. Now my 2020 ThinkPad can run Windows 98 SE with a mouse, a flash drive, wired Ethernet and sound. All through a controller that did not exist when the OS was written!

The credit for making this even possible goes to Microsoft’s original usbport.sys modular design which let a miniport slot in underneath without touching the rest of the stack. Thanks also to Maximus-Decim for NUSB, which brought that stack to Windows 98 in the first place, and to the ReactOS project, whose usbport reimplementation helped immensely. The Linux, Haiku and FreeBSD xHCI drivers were the second opinions on hardware details.

This blog post is less technical to make it more easily digestible. More technical detail is in the full documentation in the repository’s docs directory.

If you have a Windows 98/2K machine that somehow needs to use a modern USB stack, then this project is for you! Otherwise, I hope you find this project interesting to peer under the hood at how modern USB works.

Recent posts

Categories