4"""Measure raw bulk throughput to a CDC-ACM device, bypassing cdc_acm.
6The right tool for asking "what is the device firmware actually capable of,
7separate from kernel driver overhead?". ``stream_bench.py`` measures the same
8link through /dev/ttyACM*, so comparing the two attributes any shortfall to
9cdc_acm rather than to the firmware.
12 1. Detach the cdc_acm kernel driver from the bulk interface.
13 2. Claim the data interface via libusb.
14 3. Write and read bulk transfers back-to-back so the firmware never sees
16 4. Measure wall time for the whole round-trip.
18Re-attaches cdc_acm on exit via a finally block, so the device comes back as
19/dev/ttyACMx even if the benchmark fails partway -- leaving it detached would
20strand the port until replug.
22Requires root, since detaching a kernel driver does.
24Usage on the managed bench:
25 sudo /opt/ra8-hil-python/bin/python3 libusb_bench.py --vidpid 1209:000c [--bytes 1048576]
28from __future__
import annotations
34from typing
import NoReturn
39CDC_DATA_INTERFACE_CLASS = 0x0A
40USB_TRANSFER_TYPE_BULK = 0x02
41USB_TRANSFER_TYPE_MASK = 0x03
42USB_EP_DIR_IN_BIT = 0x80
45def find_bulk_endpoints(dev: usb.core.Device) -> tuple[int, int, int, int] |
None:
46 """Locate the first interface carrying a bulk IN/OUT pair.
48 Searches only CDC DATA interfaces (class 0x0A). That restriction is the
49 point: CDC ACM splits the function across a comm interface and a data
50 interface, and the bulk pair lives on the data one -- scanning every
51 interface would match the comm interface's interrupt endpoint first.
53 An interface offering only one direction is skipped rather than returned
54 half-filled, since the echo bench needs both.
56 Returns ``(cfg_value, interface_num, ep_out_addr, ep_in_addr)``, or None
57 when the device exposes no such interface.
59 cfg = dev.get_active_configuration()
61 if intf.bInterfaceClass != CDC_DATA_INTERFACE_CLASS:
65 attr = ep.bmAttributes & USB_TRANSFER_TYPE_MASK
66 if attr != USB_TRANSFER_TYPE_BULK:
68 if (ep.bEndpointAddress & USB_EP_DIR_IN_BIT) == 0:
69 ep_out = ep.bEndpointAddress
71 ep_in = ep.bEndpointAddress
72 if ep_out
is not None and ep_in
is not None:
73 return cfg.bConfigurationValue, intf.bInterfaceNumber, ep_out, ep_in
77def _open_device(vidpid: str) -> tuple[usb.core.Device, int, int, int]:
78 """Find the device and its CDC-data bulk endpoints, or exit(2)."""
79 vid_s, pid_s = vidpid.split(
":")
80 dev = usb.core.find(idVendor=int(vid_s, 16), idProduct=int(pid_s, 16))
82 print(f
"No device with vid:pid {vidpid}")
85 f
"Found {dev.manufacturer or '?'} / {dev.product or '?'} bus {dev.bus} addr {dev.address}"
87 info = find_bulk_endpoints(dev)
89 print(
"No CDC-data bulk endpoints found.")
91 cfg_val, intf_num, ep_out, ep_in = info
92 print(f
"cfg={cfg_val} intf={intf_num} ep_out=0x{ep_out:02x} ep_in=0x{ep_in:02x}")
93 return dev, intf_num, ep_out, ep_in
96def _detach_kernel_driver(dev: usb.core.Device, intf_num: int) ->
None:
97 """Unbind cdc_acm from the comm+data interface pair so libusb can claim it."""
98 for i
in (intf_num - 1, intf_num):
100 if dev.is_kernel_driver_active(i):
101 print(f
"Detaching kernel driver from interface {i}")
102 dev.detach_kernel_driver(i)
107 print(f
"Kernel-driver detach on intf {i} ignored: {e}")
111 dev: usb.core.Device, ep_out: int, ep_in: int, payload: bytes, urb_size: int
112) -> tuple[int, bytearray, float]:
113 """Chunked write+read until both directions have moved the whole payload."""
114 total_bytes = len(payload)
117 start = time.monotonic()
118 deadline = start + 30.0
119 while sent < total_bytes
or len(recv) < total_bytes:
120 if sent < total_bytes:
121 with contextlib.suppress(usb.core.USBTimeoutError):
122 sent += dev.write(ep_out, payload[sent : sent + urb_size], timeout=1000)
123 with contextlib.suppress(usb.core.USBTimeoutError):
124 got = dev.read(ep_in, urb_size, timeout=50)
127 if time.monotonic() > deadline:
130 return sent, recv, time.monotonic() - start
133def _report(sent: int, recv: bytes | bytearray, payload: bytes, elapsed: float) -> bool:
134 """Print throughput and integrity; return True when the echo matched."""
135 total_bytes = len(payload)
136 oneway = total_bytes / elapsed
if elapsed > 0
else 0.0
137 ok = bytes(recv) == payload[: len(recv)]
and len(recv) == total_bytes
138 print(f
"sent={sent} recv={len(recv)} elapsed={elapsed:.3f}s")
139 print(f
"one-way throughput : {oneway / 1024:9.1f} KB/s ({oneway / 1e6 * 8:.2f} Mbps)")
140 print(f
"aggregate : {2 * oneway / 1024:9.1f} KB/s ({2 * oneway / 1e6 * 8:.2f} Mbps)")
141 print(f
"integrity : {'OK' if ok else 'FAIL'}")
143 for i
in range(
min(len(recv), len(payload))):
144 if recv[i] != payload[i]:
145 lo, hi = max(0, i - 8),
min(len(recv), i + 16)
146 print(f
" first mismatch at offset {i}")
147 print(f
" expected: {payload[lo:hi].hex()}")
148 print(f
" received: {bytes(recv[lo:hi]).hex()}")
153def bench(vidpid: str, total_bytes: int, urb_size: int) -> NoReturn:
154 """Run one synchronous bulk echo benchmark over raw libusb, then exit.
156 Never returns -- exits 0 when the echo matched, 1 when it did not.
158 The kernel driver is re-attached in a finally block, so an exception or a
159 failed run still leaves the device usable as /dev/ttyACMx; without that a
160 crash here would strand the port until it was physically replugged.
162 dev, intf_num, ep_out, ep_in = _open_device(vidpid)
163 _detach_kernel_driver(dev, intf_num)
165 usb.util.claim_interface(dev, intf_num)
166 payload = bytes((i * 131 + 17) & 0xFF
for i
in range(total_bytes))
167 sent, recv, elapsed = _pump(dev, ep_out, ep_in, payload, urb_size)
168 sys.exit(0
if _report(sent, recv, payload, elapsed)
else 1)
170 with contextlib.suppress(Exception):
171 usb.util.release_interface(dev, intf_num)
173 for i
in (intf_num - 1, intf_num):
174 with contextlib.suppress(Exception):
175 dev.attach_kernel_driver(i)
178def main() -> NoReturn:
179 """Parse the command line and run one libusb bulk echo benchmark.
181 Never returns: ``bench`` exits with the verdict (0 clean, 1 integrity
182 failure, 2 device not found or no bulk endpoints).
184 ap = argparse.ArgumentParser()
185 ap.add_argument(
"--vidpid", required=
True, help=
"VID:PID, e.g. 1209:000c")
186 ap.add_argument(
"--bytes", type=int, default=1048576)
187 ap.add_argument(
"--urb", type=int, default=4096)
188 args = ap.parse_args()
189 bench(args.vidpid, args.bytes, args.urb)
192if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.