ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
libusb_bench.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Measure raw bulk throughput to a CDC-ACM device, bypassing cdc_acm.
5
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.
10
11How it works:
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
15 the host idle.
16 4. Measure wall time for the whole round-trip.
17
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.
21
22Requires root, since detaching a kernel driver does.
23
24Usage on the managed bench:
25 sudo /opt/ra8-hil-python/bin/python3 libusb_bench.py --vidpid 1209:000c [--bytes 1048576]
26"""
27
28from __future__ import annotations
29
30import argparse
31import contextlib
32import sys
33import time
34from typing import NoReturn
35
36import usb.core
37import usb.util
38
39CDC_DATA_INTERFACE_CLASS = 0x0A # USB CDC data class (bInterfaceClass)
40USB_TRANSFER_TYPE_BULK = 0x02 # bmAttributes transfer-type field value
41USB_TRANSFER_TYPE_MASK = 0x03 # mask to extract transfer type from bmAttributes
42USB_EP_DIR_IN_BIT = 0x80 # bEndpointAddress direction bit: 1 = IN
43
44
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.
47
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.
52
53 An interface offering only one direction is skipped rather than returned
54 half-filled, since the echo bench needs both.
55
56 Returns ``(cfg_value, interface_num, ep_out_addr, ep_in_addr)``, or None
57 when the device exposes no such interface.
58 """
59 cfg = dev.get_active_configuration()
60 for intf in cfg:
61 if intf.bInterfaceClass != CDC_DATA_INTERFACE_CLASS:
62 continue
63 ep_out = ep_in = None
64 for ep in intf:
65 attr = ep.bmAttributes & USB_TRANSFER_TYPE_MASK
66 if attr != USB_TRANSFER_TYPE_BULK:
67 continue # Not bulk
68 if (ep.bEndpointAddress & USB_EP_DIR_IN_BIT) == 0:
69 ep_out = ep.bEndpointAddress
70 else:
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
74 return None
75
76
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))
81 if dev is None:
82 print(f"No device with vid:pid {vidpid}")
83 sys.exit(2)
84 print(
85 f"Found {dev.manufacturer or '?'} / {dev.product or '?'} bus {dev.bus} addr {dev.address}"
86 )
87 info = find_bulk_endpoints(dev)
88 if info is None:
89 print("No CDC-data bulk endpoints found.")
90 sys.exit(2)
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
94
95
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):
99 try:
100 if dev.is_kernel_driver_active(i):
101 print(f"Detaching kernel driver from interface {i}")
102 dev.detach_kernel_driver(i)
103 except (
104 NotImplementedError,
105 usb.core.USBError,
106 ) as e: # short 2-iter loop; inline try is clearest
107 print(f"Kernel-driver detach on intf {i} ignored: {e}")
108
109
110def _pump(
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)
115 sent = 0
116 recv = bytearray()
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)
125 if got:
126 recv += bytes(got)
127 if time.monotonic() > deadline:
128 print("TIMEOUT")
129 break
130 return sent, recv, time.monotonic() - start
131
132
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'}")
142 if not ok:
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()}")
149 break
150 return ok
151
152
153def bench(vidpid: str, total_bytes: int, urb_size: int) -> NoReturn:
154 """Run one synchronous bulk echo benchmark over raw libusb, then exit.
155
156 Never returns -- exits 0 when the echo matched, 1 when it did not.
157
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.
161 """
162 dev, intf_num, ep_out, ep_in = _open_device(vidpid)
163 _detach_kernel_driver(dev, intf_num)
164 try:
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)
169 finally:
170 with contextlib.suppress(Exception):
171 usb.util.release_interface(dev, intf_num)
172 # Re-attach the kernel driver so the device comes back as ttyACM.
173 for i in (intf_num - 1, intf_num):
174 with contextlib.suppress(Exception):
175 dev.attach_kernel_driver(i)
176
177
178def main() -> NoReturn:
179 """Parse the command line and run one libusb bulk echo benchmark.
180
181 Never returns: ``bench`` exits with the verdict (0 clean, 1 integrity
182 failure, 2 device not found or no bulk endpoints).
183 """
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)
190
191
192if __name__ == "__main__":
193 main()
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298
#define min(x, y)
Untyped minimum shim used by the SOUP's buffer clamping.
Definition xz_config.h:157