Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

usbspeed

Windows tells you a 10 Gbps USB device is running at 480 Mbps. It is off by 20.8x, and there is no UI anywhere in Windows that will tell you otherwise.

usbspeed is a single zero-dependency PowerShell script that reads the real negotiated link speed of every USB device on your machine, draws the hub tree, and tells you which devices are plugged into a port that is holding them back.

It is read-only. It never resets a port, never cycles power, never writes to the registry, and never touches a device. See Read-only, and how that is proved.


The bug in Windows that this exists for

Every tool that reports USB speed on Windows -- Device Manager, Get-PnpDevice, USBView, WMI -- ultimately calls IOCTL_USB_GET_NODE_CONNECTION_INFORMATION_EX. That structure's Speed field is a single byte with four values:

Speed meaning link rate
0 UsbLowSpeed 1.5 Mbps
1 UsbFullSpeed 12 Mbps
2 UsbHighSpeed 480 Mbps
3 UsbSuperSpeed 5 Gbps

It stops at 3, and in practice it stops at 2. On the machine this was developed on, a USB 3.1 Gen 2 hub negotiated at SuperSpeedPlus, 10 Gbps, and that ioctl reported Speed = 2 -- High-Speed, 480 Mbps. Not 5 Gbps. Not "unknown". It reported the USB 2.0 rate for a link running twenty times faster.

The truth is sitting in a second ioctl, IOCTL_USB_GET_NODE_CONNECTION_INFORMATION_EX_V2, which returns a flags word:

bit meaning
0 DeviceIsOperatingAtSuperSpeedOrHigher
1 DeviceIsSuperSpeedCapableOrHigher
2 DeviceIsOperatingAtSuperSpeedPlusOrHigher
3 DeviceIsSuperSpeedPlusCapableOrHigher

Bits 0 and 2 are the actual link state. Bits 1 and 3 are what the device could do. No Windows UI shows any of them. usbspeed shows all four.

The trap that keeps this ioctl undiscovered

_EX_V2 returns ERROR_INVALID_PARAMETER (87) on every single call unless the caller sets SupportedUsbProtocols.Usb300 -- bit 2, value 0x04 -- in the input buffer at offset 8. Pass zeroes and it fails forever, which reads exactly like "this machine does not support it". A buffer-size sweep finds nothing, because size was never the problem. That one write is the whole difference between this tool and Device Manager.


What it looks like

Real output from the development machine (a Surface Book 3), unedited:

USB Root Hub (USB 3.0)  (RootHub, 18 ports)
|- port 2: Generic USB Hub [0x05E3:0x0610]
|    High-Speed  -> 480 Mbps
|    INTERNAL-BELOW: SuperSpeed-capable, running at USB 2.0 speed on an internal
|    port (normal for the 2.0 half of a USB 3 hub chip)
|  \- port 2: Surface USB Hub [0x045E:0x0C14]
|     \- port 3: USB Composite Device [0x1532:0x0099]
|          Full-Speed  -> 12 Mbps
|- port 9: Xbox Wireless Adapter for Windows [0x045E:0x091E]
|- port 10: Intel(R) Wireless Bluetooth(R) [0x8087:0x0026]
\- port 14: Generic SuperSpeed USB Hub [0x05E3:0x0625]
     SuperSpeedPlus (Windows reports High-Speed)  -> 10 Gbps

Where Windows understates the link speed:
  * Generic SuperSpeed USB Hub [0x05E3:0x0625]: Windows says 480 Mbps, actually 10 Gbps  (20.8x)
  * Generic SuperSpeed USB Hub [0x045E:0x0C15]: Windows says 480 Mbps, actually 10 Gbps  (20.8x)

No device is running below its capability on a port you can reach.

(The tool draws real box-drawing characters; they are transliterated to ASCII above so they survive GitHub's markdown. The tool does the same thing automatically if your console encoding cannot represent them -- see Encoding.)


Why you would run it

  • Your external SSD is slow. Is it the drive, or did you plug a 10 Gbps enclosure into a port that negotiated USB 2.0? Windows will tell you "480 Mbps" either way. usbspeed tells you whether the device is capable of more.
  • Your capture card drops frames. A capture card on a High-Speed link cannot carry 4K60. This tells you in one line whether the link is the problem.
  • Your USB microphone crackles. Same question, same answer.
  • You have a front-panel USB-C port and a rear one and no idea which is which. The tool reports the connector properties, including whether the port is USB-C and which port is its companion on the other bus.
  • You want to know what your dock is actually doing. It draws the full tree, so you can see which hub is between you and the device.

Usage

powershell.exe -NoProfile -ExecutionPolicy Bypass -File usbspeed.ps1

Use that exact invocation. Most Windows machines ship with an execution policy that refuses to run .ps1 files. -ExecutionPolicy Bypass is process-scoped: it applies to that one PowerShell process and exits with it. It changes no setting on your machine, and this tool will never ask you to change one.

flag effect
(none) draw the tree, list understated links, list actionable problems
-All also show every port, including empty ones
-Info diagnostics: per-hub ioctl availability, port-count agreement between two independent ioctls, V2 coverage
-Json emit the whole model as JSON on stdout and nothing else
-FromJson <path> re-render a previously captured -Json file instead of reading hardware
-Quiet suppress commentary, keep findings
-NoRun define the functions and return without doing anything (for test harnesses)

-Json plus -FromJson means you can capture a machine's USB topology, send the file to someone else, and have them see exactly what you see. It is also how the test suite proves the renderer is deterministic: live -> snap1 -> snap2 -> snap3 produces snap2 byte-identical to snap3.

Elevation

It works without administrator rights. Elevation is not required and is not requested.


The design decision that matters: not crying wolf

The obvious rule -- "SuperSpeed-capable but not operating at SuperSpeed means something is wrong" -- is wrong, and it fires constantly.

Every USB 3 hub chip enumerates as two logical devices: a USB 2.0 hub and a SuperSpeed hub. The 2.0 half is supposed to run at High-Speed. It is capability-flagged as SuperSpeed-capable because the silicon is, but it is not a fault, it is the design. A tool that flags it reports a fault on every USB 3 hub in the machine, including ones soldered to the motherboard that you could not change if you wanted to.

The discriminator is PortIsUserConnectable, from IOCTL_USB_GET_PORT_CONNECTOR_PROPERTIES. If you cannot physically reach the connector, moving a cable is not a remedy, so it is not an actionable finding.

BELOW-CAPABILITY  (actionable)  = SuperSpeed-capable AND not operating at SuperSpeed
                                  AND connected OK AND the port is user-connectable
INTERNAL-BELOW    (informational) = the same thing on a port you cannot reach

A near-miss worth recording: the intuitive alternative is to compare the device's bcdUSB. It does not work. A SuperSpeed device that has fallen back to a USB 2.0 link reports bcdUSB = 0x0210 by specification -- 0x0210 means "has a BOS descriptor", not "is a USB 2.1 device" -- which is byte-identical to what a plain USB 2.1 device reports. The field cannot distinguish the two cases, and building the classifier on it would have produced a tool that was confidently wrong.


Read-only, and how that is proved

USB hub ioctls include USB_HUB_CYCLE_PORT and USB_RESET_HUB. Calling either one power-cycles or resets live hardware. usbspeed calls neither, and the test suite enforces it three ways:

  1. Source scan. selftest.ps1 greps the source for the function codes of every mutating USB ioctl and fails the build if one appears.
  2. Snapshot equality. realcheck.ps1 captures HKLM\SYSTEM\CurrentControlSet\Enum\USB and the full present-device list before and after a complete run and requires both to be byte-identical.
  3. Repeatability. Two full runs back to back must produce the same topology.

There is no -Fix mode, no undo journal, and nothing to undo. That is the correct design for this tool and it is stated here rather than implied.


Verification

Three suites ship with the tool. All three are in the repository and all three pass on the development machine.

selftest.ps1 -- hermetic, 214 assertions, 0 failures

Synthetic ioctl output with a different distinctive value planted in every field, so a parser that transposes two fields cannot pass. Twelve sections: field-by-field parsing, speed resolution across all flag combinations, the cry-wolf classifier, descriptor sanity (driven with the real garbage values observed on empty ports: bLength = 7, ConnectionStatus = 117440512), bcdUSB rendering, number formatting, instance-id derivation, hub records, multi-line assembly, malformed input, glyph fallback, and source hygiene.

realcheck.ps1 -- real hardware, 369 assertions, 0 failures

claim result
fields cross-checked against an independent implementation 28, 0 mismatches
negative controls (corrupted references) rejected 44 of 44
undecidable mutations, excluded and named 5
devices covered 7 of 7 connected devices
ports answering _EX_V2 34 of 34
hubs where two independent ioctls agree on the port count 6 of 6

The independent reference is written with a deliberately different technique: the tool reads USB topology through hub DeviceIoControl calls, while the reference walks the Configuration Manager / PnP property tree (Get-PnpDevice, DEVPKEY properties, LocationInfo strings). Two different subsystems, two different code paths, same vendor ids, product ids, hub relationships and port numbers.

The negative controls are what make "0 mismatches" mean anything. A comparison that tolerates everything reports zero mismatches against anything. So after the real comparison passes, the same comparison is replayed against deliberately corrupted values -- a vendor id with one hex digit flipped, a port number read as hex instead of decimal, a parent/child relationship inverted, a speed read from the wrong field -- and every one must be rejected. 44 of 44 were. Five could not be decided and are printed individually rather than counted as passes:

[UNDETECTABLE] 0x05E3:0x0610@2: port-as-hex (decimal and hex readings are identical for 2)

Port 2 read as hex is still 2. That mutation is genuinely undetectable, and saying so is more useful than quietly inflating the score.

Ground truth is planted inside real data. Synthetic records carrying impossible-to-collide values (0xBEEF:0xCAFE, device address 97) are appended to the genuine ioctl output and the tool must find them and must not let them leak into any real record.

The headline feature is driven end to end by real hardware. A real port's PortIsUserConnectable bit is flipped in the captured data, and the classifier must move that exact device from INTERNAL-BELOW to BELOW-CAPABILITY. The feature is not trusted because an API returned a number without erroring.

The tool's own -Json is fed back through bounds checks, including aggregate checks: per-hub port record counts must sum to the global total, device addresses must lie in the USB range 1-127, open pipe counts must be plausible, and no device may be counted twice. Individually-clamped readings that go wrong only in aggregate are a real bug class.

mutate.ps1 -- do the tests actually test anything?

A passing suite only proves the tests tolerate the current code. mutate.ps1 breaks the tool deliberately, one edit at a time, and requires a suite to notice. It mutates both layers: PowerShell classification logic (judged by selftest.ps1) and the native ioctl struct offsets (judged by realcheck.ps1, because only real hardware can tell that DeviceAddress was read at the natural-alignment offset instead of the pack(1) one).

mutation score: 28/28
invalid controls (anchor did not match exactly once): 0
excluded as declared no-ops: 1

Eight of those 28 die only on real hardware. The hermetic suite cannot see them at all, which is the argument for shipping both suites rather than one.

Four rules keep the score honest:

  • A baseline control. Before judging a single mutant, the unmodified source is run through the exact same runner and both suites must pass. A harness that cannot tell a healthy tool from a broken one reports a perfect score against everything -- see the note below, because that is precisely what happened here.
  • Anchor validation. A mutation whose anchor text is not in the source patches nothing, runs the unmodified tool, and is reported as "killed" by a suite that never saw a mutant. Every anchor must match exactly once or the mutation is reported as an INVALID CONTROL and excluded.
  • Paired edits. Redundantly defensive code produces equivalent mutants -- remove one layer and nothing observable changes. The harness applies several edits as one mutation, so the redundancy can be removed together rather than deleting the assertion that "failed".
  • No-op exclusion. A mutation with no semantic effect cannot be killed by anything. It is declared, run, reported and excluded from the score instead of counted as a pass.

A mutant can hang rather than fail, so every suite run is under a timeout. That is not hypothetical: see the port-count clamp below.

Run without administrator rights

Verified at reduced trust level (runas /trustlevel:0x20000), which is how most people will run it:

Hubs
  RootHub   ports= 5 descPorts= 5 agree=True  USB Root Hub (USB 3.0)
  Usb30Hub  ports= 3 descPorts= 3 agree=True  Generic SuperSpeed USB Hub
  RootHub   ports=18 descPorts=18 agree=True  USB Root Hub (USB 3.0)
  Usb30Hub  ports= 2 descPorts= 2 agree=True  Generic SuperSpeed USB Hub
  Usb20Hub  ports= 3 descPorts= 3 agree=True  Surface USB Hub
  Usb20Hub  ports= 3 descPorts= 3 agree=True  Generic USB Hub

  ports answering INFORMATION_EX_V2 : 34 of 34

Every hub opened, every port answered, every friendly name resolved, and the 20.8x understatement was still detected. Nothing here needs elevation.


Notes from building this

Things that cost real debugging time and are not in any documentation.

A mutation harness that cannot recognise a healthy tool reports a perfect score against everything. Start-Process -PassThru without -Wait returns a Process object whose ExitCode is $null -- even after WaitForExit() has returned $true and HasExited is $true. The harness had been switched to the no--Wait form to add a timeout, so every run read $null, $null -ne 0 evaluated to true, and all 28 mutations were reported as killed. Including a mutation that only reworded a comment. The tell was that a deliberately inert edit "failed" a suite, and that the suites were suddenly finishing far too quickly. Touching $pr.Handle before waiting makes PowerShell retain the handle and the exit code survives. The durable lesson is the reason the baseline control exists: a harness must first prove it can tell a pass from a failure, or its numbers mean nothing.

A wrong port count does not make the tool fail, it makes it hang. IOCTL_USB_GET_HUB_INFORMATION_EX returns HighestPortNumber at offset 4; read it at offset 6 and you land on the hub descriptor header, yielding 0x2909 = 10,505 ports per hub. The enumeration loop issues three ioctls per port, so six hubs became roughly 190,000 ioctls and the tool simply never returned. A USB hub descriptor stores bNumberOfPorts in a single byte, so 255 is a hard spec ceiling; enumeration is now bounded by it, the clamp is reported rather than silently truncating, and the mutation harness runs every suite under a timeout. This was found by mutation testing, not by testing -- nothing in normal operation would have revealed it.

PowerShell's -eq is case-insensitive on strings, and so are its hashtable keys. Hub path matching lowercased both sides and compared with -eq, which is three layers of case-insensitivity where one would do. Removing both .ToLowerInvariant() calls changed nothing observable, so the mutation survived -- not because the code was untested, but because it was redundant. The honest fix is to remove all the redundancy in one paired mutation (and switch -eq to -ceq), not to delete the test that "failed" to catch it.

Marshal.ReadUInt16 does not exist. System.Runtime.InteropServices.Marshal has ReadByte, ReadInt16, ReadInt32, ReadInt64 and ReadIntPtr -- no unsigned variants. ReadInt16 returns a signed short, so Intel's vendor id 0x8087 reads back as -32633 and formatting it as hex yields FFFF8087. Every 16-bit read must be masked with & 0xFFFF.

USB_NODE_CONNECTION_INFORMATION_EX is pack(1). Laying it out at natural alignment puts DeviceAddress two bytes late and produces device addresses that are exact multiples of 256. Verified offsets: ConnectionIndex@0, bLength@4, bDescriptorType@5, bcdUSB@6, idVendor@12, idProduct@14, bcdDevice@16, bNumConfigurations@21, CurrentConfigurationValue@22, Speed@23, DeviceIsHub@24, DeviceAddress@25, NumberOfOpenPipes@27, ConnectionStatus@31.

DEVPKEY_Device_Address on a USB device is the port number, not the USB bus address. The hub on port 14 reports Address = 14 while the ioctl reports USB address 1; the hub on port 2 reports Address = 2 against USB address 4. LocationInfo (Port_#0014.Hub_#0001) confirms the port reading. A field whose name is not what it means.

ContainerId cannot pair the 2.0 and SuperSpeed halves of a dual-bus device. It looks like exactly the right tool. On this machine every internal device reports the null chassis GUID {00000000-0000-0000-FFFF-FFFFFFFFFFFF}, which would have "paired" eight unrelated devices including both PCI host controllers. The reciprocal CompanionPortNumber from IOCTL_USB_GET_PORT_CONNECTOR_PROPERTIES is the field that actually works.

An empty port reports Speed = 0, which is a valid value meaning Low-Speed. Mapping it through the speed table invents a 1.5 Mbps link on every vacant connector in the machine. Any lookup table whose index 0 is a legitimate value needs an explicit "nothing is connected" guard before the lookup. The hermetic suite caught this one.

$pid is a read-only automatic variable in PowerShell, and PID is the single most natural name in a USB tool. $pid = 0x0610 throws "Cannot overwrite variable PID because it is read-only or constant." PowerShell variable names are case-insensitive, so $PID, $pid and $Pid are all the same variable.

Assigning a function's result captures its entire pipeline output, not just its return value. $script:ExitCode = Invoke-Main silently swallowed the whole -Json payload into the exit-code variable, so -Json emitted zero bytes while every other mode worked perfectly. The symptom was masked by a second trap: the harness checked @(ConvertFrom-Json '').Count and got 1, because ConvertFrom-Json emits an array as a single pipeline object. It reported a phantom success on an empty string.

Get-PnpDevice -PresentOnly with no class filter, plus a per-device Get-PnpDeviceProperty, takes minutes -- one CIM round trip per device across several hundred nodes. The name lookup here is a single registry walk of HKLM\SYSTEM\CurrentControlSet\Enum\USB instead: faster, works without elevation, no CIM.

Do not blind-scan ioctl function codes against live hardware. Two of the codes in the USB hub range reset ports and cycle hub power. Enumerating them "to see which ones respond" executes them.


Encoding

The source is pure ASCII with no byte-order mark. Box-drawing characters are built from code points at runtime ([char]0x251C and friends) rather than embedded, so the file cannot be misread as ANSI by PowerShell 5.1 and cannot be corrupted in transit. At startup each glyph is round-tripped through the console's actual output encoding, and if it does not survive the tool falls back to |, \, - and +. The fallback is tested against US-ASCII (20127) and Latin-1 (28591).

Not against codepage 437 -- cp437 genuinely contains the box-drawing glyphs (they are the original IBM PC characters), so a "fallback test" against it fails for the wrong reason.

The same trap appears when capturing the tool's output to a file. A redirected child process here encoded its output as cp437; reading that file back as UTF-8 produces replacement characters and makes a perfectly correct run look corrupted. Decoding the same bytes as cp437 shows |- box drawing exactly as intended. If the output looks like mojibake, check what encoding you are reading with before changing anything.


Requirements

  • Windows 8 or later (the _EX_V2 ioctl was introduced in Windows 8)
  • Windows PowerShell 5.1, which ships in the box. No modules, no downloads, no installer.
  • The embedded C# is compiled by the in-box .NET Framework compiler via Add-Type. Nothing is fetched from the internet.

Tested on Windows 11 build 26200 (25H2), PowerShell 5.1.26100.9444.

Files

file what it is
usbspeed.ps1 the tool
selftest.ps1 hermetic suite, synthetic fixtures with all-distinct ground truth
realcheck.ps1 real-hardware suite, independent cross-check and negative controls
mutate.ps1 mutation harness that grades the two suites above

See also

License

MIT

About

Windows reports High-Speed (480 Mbps) for a USB device negotiated at 10 Gbps - a 20x understatement from the ioctl every USB tool uses - while separately computing a this-could-go-faster bit no UI shows. usbspeed reads both and flags devices running below capability. Zero dependencies, read-only.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages