From 38e17e172cfb3383dc550b86c2468f3a907c0043 Mon Sep 17 00:00:00 2001 From: Gilang Date: Wed, 9 Sep 2026 00:38:17 +0700 Subject: [PATCH] usb: pipelined register writes for the bring-up; Jaguar3 stage timing InitWrite on the RTL8812EU is ~14k synchronous EP0 round trips and nothing else. InitTimer now brackets every Jaguar3 rtw_hal_init / InitWrite stage and reports the control-transfer count per stage (UsbXferCount.h), which is the unit the bring-up is actually paid in. init.timing gains an `xfers` field; docs/logging.md carries the schema. A transfer costs 76-80 us synchronous on an embedded host (ssc338q) and ~27 us pipelined 8-deep. EP0 completes URBs in submission order, so IRtlTransport::write_batch_begin/end lets UsbTransport queue writes as async URBs and wait only on reads (submitted behind the queue), bulk transfers and flush_writes. Jaguar3 InitWrite runs its whole bring-up in one batch (RAII scope, closed before the coex thread starts); the ms-scale settle delays flush first. The three methods default to no-ops, so PCIe is unaffected. Batches are single-threaded by contract: open one only while no other thread touches the transport, and close it before any worker starts. A drain that times out cancels what is still submitted and keeps pumping for the cancellations rather than declaring the queue empty: an in-flight count zeroed by hand goes negative on the late callback, which silently disables every later drain, returns a slot to the free list twice, and leaves the destructor freeing a transfer libusb still owns. Slots that genuinely cannot be reaped (dead event loop, yanked device) are retired for the session and leaked at teardown instead. Separately, the RF radio-table load is write-only: bits [31:20] of the direct window read back 0 for all 1540 entries, cold and warm, so the vendor's MASK20BITS read-modify-write preserved nothing while paying a synchronous read per entry -- about half the RF-table stage. Measured on one drone-side unit: warm InitWrite 1.30 -> 0.65 s, cold 2.04 -> ~0.7 s. Init (RX-only) opens no batch yet and is unmeasured on a ground-station card, so the RX bring-up path is unchanged by this commit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JiAS32956Z3cmTbnmcXYkT --- docs/logging.md | 2 +- src/InitTimer.h | 28 ++++- src/RtlAdapter.h | 4 + src/RtlTransport.h | 14 +++ src/UsbTransport.cpp | 193 +++++++++++++++++++++++++++++++ src/UsbTransport.h | 63 ++++++++++ src/UsbXferCount.h | 14 +++ src/jaguar3/CLAUDE.md | 23 ++++ src/jaguar3/HalJaguar3.cpp | 48 ++++++-- src/jaguar3/HalJaguar3.h | 2 +- src/jaguar3/Halrf8822e.cpp | 1 + src/jaguar3/Halrf8822e.h | 2 +- src/jaguar3/RtlJaguar3Device.cpp | 32 +++++ 13 files changed, 409 insertions(+), 17 deletions(-) create mode 100644 src/UsbXferCount.h diff --git a/docs/logging.md b/docs/logging.md index c843cfdc..a05272c7 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -76,7 +76,7 @@ Emitters: L = library, RX/TX/... = demo. Optional fields in [brackets]; ### Init / infrastructure | ev | emitter | fields | |---|---|---| -| `init.timing` | L (`src/InitTimer.h`) + demos | stage ("scope.stage", e.g. "demo.first_rx_frame", "txdemo.first_tx_submit"), ms | +| `init.timing` | L (`src/InitTimer.h`) + demos | stage ("scope.stage", e.g. "demo.first_rx_frame", "txdemo.first_tx_submit"), ms, xfers (USB vendor control transfers the stage spent; 0 on PCIe) | | `adapter.caps` | RX, TX, doctor, txpower (`examples/common/caps_event.h`) | supported, chip, names, chip_id "0x..", gen, variant, transport, tx_chains, rx_chains, n_ss, stbc, ldpc, sgi, bw_max, bw[] (MHz), txpwr_max, txpwr_step_qdb, txpwr_step_measured, txpwr_min_qdb, txpwr_max_qdb, txpwr_rate_diffs, txpwr_rate_diffs_hw, txpwr_rate_diffs_measured, tune_2g4[]\|null, tune_5g[]\|null, char_2g4[]\|null, char_5g[]\|null, ldpc_rx_ht, ldpc_rx_vht, ldpc_rx_flag, per_pkt_txpwr, narrowband, fastretune, ack_responder, tx_retry_limit, he_er_su, per_chain_rssi | | `debug.wreg` | L (`DEVOURER_LOG_WRITES`) | addr "0x0nnn", width, val "0x…" | | `hop.prof` | L (`DEVOURER_HOP_PROF`) | gen, ch, `_us`…, total_us | diff --git a/src/InitTimer.h b/src/InitTimer.h index 133c36b8..504448ff 100644 --- a/src/InitTimer.h +++ b/src/InitTimer.h @@ -6,10 +6,14 @@ #include #include "logger.h" +#include "UsbXferCount.h" /* Stage timer for init-path profiling. Emits one event per checkpoint: * - * {"ev":"init.timing","stage":".","ms":N} + * {"ev":"init.timing","stage":".","ms":N,"xfers":K} + * + * `xfers` is the number of USB control transfers (register reads/writes) + * the stage spent — see UsbXferCount.h; 0 on the PCIe transport. * * `stage()` reports time since the previous checkpoint (or construction); * `total()` reports time since construction. Always-on: a handful of events @@ -21,23 +25,33 @@ class InitTimer { public: InitTimer(Logger_t logger, const char *scope) : _logger{std::move(logger)}, _scope{scope}, _start{clock::now()}, - _last{_start} {} + _last{_start}, _x_start{xfers()}, _x_last{_x_start} {} void stage(const char *name) { const auto now = clock::now(); - emit(name, ms(_last, now)); + const auto x = xfers(); + emit(name, ms(_last, now), static_cast(x - _x_last)); _last = now; + _x_last = x; } - void total() { emit("total", ms(_start, clock::now())); } + void total() { + emit("total", ms(_start, clock::now()), + static_cast(xfers() - _x_start)); + } private: - void emit(const char *name, long long millis) { + static uint64_t xfers() { + return devourer::usb_ctrl_xfers().load(std::memory_order_relaxed); + } + + void emit(const char *name, long long millis, long long nx) { char stage[96]; std::snprintf(stage, sizeof(stage), "%s.%s", _scope, name); devourer::Ev(_logger->events(), "init.timing") .f("stage", stage) - .f("ms", millis); + .f("ms", millis) + .f("xfers", nx); } static long long ms(clock::time_point from, clock::time_point to) { @@ -49,6 +63,8 @@ class InitTimer { const char *_scope; clock::time_point _start; clock::time_point _last; + uint64_t _x_start; + uint64_t _x_last; }; #endif /* INIT_TIMER_H */ diff --git a/src/RtlAdapter.h b/src/RtlAdapter.h index ffb87656..98e90ed4 100644 --- a/src/RtlAdapter.h +++ b/src/RtlAdapter.h @@ -88,6 +88,10 @@ class RtlAdapter { /* Pre-power-on HCI programming (rtw88 rtw_hci_setup slot): PCIe TRX ring * registers; no-op on USB. Call per bring-up attempt, before power-on. */ void hci_setup() { _transport->hci_setup(); } + /* Pipelined register writes — see IRtlTransport::write_batch_begin. */ + void write_batch_begin() { _transport->write_batch_begin(); } + void write_batch_end() { _transport->write_batch_end(); } + void flush_writes() { _transport->flush_writes(); } /* Kernel-style async RX: keep n_urbs concurrent bulk-IN transfers in flight * (USB) or reap the RX buffer-descriptor ring (PCIe), invoking diff --git a/src/RtlTransport.h b/src/RtlTransport.h index 8afbb1ba..1eaf0ea1 100644 --- a/src/RtlTransport.h +++ b/src/RtlTransport.h @@ -68,6 +68,20 @@ class IRtlTransport { return read32(static_cast(addr)); } + /* ---- pipelined register writes ---- + * Inside a write batch, register writes are submitted as asynchronous + * in-order transfers and only a read (or a bulk transfer, or flush_writes) + * waits for them. Bring-up is ~14k synchronous EP0 round trips at ~80 us + * each on an embedded host; pipelined, a write costs ~27 us (measured, + * ssc338q + RTL8812EU, depth >= 8). Correctness rests on EP0 completing + * URBs in submission order, so a read that follows a write still sees it. + * Single-threaded by contract: open a batch only while no other thread + * touches the transport (the Jaguar3 InitWrite/Init bring-up), and close + * it before any worker thread starts. Defaults are no-ops (PCIe). */ + virtual void write_batch_begin() {} + virtual void write_batch_end() {} + virtual void flush_writes() {} + /* ---- frame plane ---- */ /* Fire-and-forget data TX (the send_packet hot path). `ep` is the USB * bulk-OUT endpoint choice; the PCIe transport ignores it (the ring is diff --git a/src/UsbTransport.cpp b/src/UsbTransport.cpp index a357d1ea..fc56d4fc 100644 --- a/src/UsbTransport.cpp +++ b/src/UsbTransport.cpp @@ -336,6 +336,23 @@ UsbTransport::UsbTransport(libusb_device_handle *dev_handle, Logger_t logger, } UsbTransport::~UsbTransport() { + flush_writes(); + int leaked = 0; + for (auto *w : _aw_all) { + /* libusb forbids freeing an active transfer, and its callback still + * writes through `w`. If the drain above could not reap it (dead event + * loop / yanked device), leaking the slot is the lesser evil: a callback + * that somehow fires later touches leaked memory, whereas freeing here + * hands libusb a dangling transfer it is still holding. */ + if (w->inflight) { + ++leaked; + continue; + } + libusb_free_transfer(w->t); + delete w; + } + if (leaked) + _logger->error("USB: leaked {} unreaped pipelined transfer slot(s)", leaked); /* Backstop only. The device's Stop()/destructor quiesces TX while every * owner is alive, which is the path that makes teardown safe; reaching the * transport destructor with transfers still in flight means the caller tore @@ -352,7 +369,181 @@ UsbTransport::~UsbTransport() { quiesce_tx(); } +/* ---- pipelined register writes ------------------------------------------ + * Submission order == completion order on EP0, so a pending queue of writes + * followed by a read behaves exactly like the synchronous sequence; the win + * is that the host does not sit through a full URB round trip per write. */ +void UsbTransport::write_batch_begin() { + if (_batch) + return; + /* A session that already failed to reap its transfers has a short pool and + * a suspect event loop; stay synchronous rather than pipeline into it. */ + if (_aw_abandoned) + return; + if (_aw_all.empty()) { + for (int i = 0; i < kAsyncWriteDepth; ++i) { + auto *w = new AsyncWrite{}; + w->t = libusb_alloc_transfer(0); + w->self = this; + _aw_all.push_back(w); + _aw_free.push_back(w); + } + } + _aw_errors = 0; + _batch = true; +} + +void UsbTransport::write_batch_end() { + flush_writes(); + if (_batch && _aw_errors) + _logger->error("USB: {} pipelined register write(s) failed in this batch", + _aw_errors); + _batch = false; +} + +void LIBUSB_CALL UsbTransport::async_write_cb(libusb_transfer *t) { + auto *w = static_cast(t->user_data); + UsbTransport *self = w->self; + self->_aw_inflight--; + self->_aw_completed++; + w->inflight = false; + w->done = true; + w->status = t->status; + w->actual = t->actual_length; + if (t->status != LIBUSB_TRANSFER_COMPLETED || + t->actual_length != t->length - LIBUSB_CONTROL_SETUP_SIZE) + self->_aw_errors++; + /* The slot goes back on the free list here; a reader that is waiting on + * this very slot copies its data out before it submits anything else + * (single-threaded by contract), so the buffer is still intact. */ + self->_aw_free.push_back(w); +} + +UsbTransport::AsyncWrite *UsbTransport::async_take_slot() { + while (_aw_free.empty()) { + if (!async_wait_progress()) { + flush_writes(); /* recovers the pool on a stuck queue */ + if (_aw_free.empty()) + return nullptr; + } + } + AsyncWrite *w = _aw_free.back(); + _aw_free.pop_back(); + w->done = false; + w->inflight = false; + w->status = -1; + w->actual = 0; + return w; +} + +bool UsbTransport::async_read(uint16_t wvalue, uint16_t windex, void *data, + size_t n) { + if (n > kAsyncMaxPayload) + return false; + AsyncWrite *w = async_take_slot(); + if (!w) + return false; + libusb_fill_control_setup(w->buf, REALTEK_USB_VENQT_READ, 5, wvalue, windex, + static_cast(n)); + libusb_fill_control_transfer(w->t, _dev_handle, w->buf, async_write_cb, w, + USB_TIMEOUT); + const int rc = libusb_submit_transfer(w->t); + if (rc != 0) { + _aw_free.push_back(w); + _aw_errors++; + _logger->error("USB: pipelined read submit failed ({})", rc); + return false; + } + w->inflight = true; + _aw_inflight++; + while (!w->done) { + if (!async_wait_progress()) { + flush_writes(); /* cancels + recovers; w->done is set by the cancel */ + break; + } + } + if (!w->done || w->status != LIBUSB_TRANSFER_COMPLETED || + w->actual != static_cast(n)) + return false; + std::memcpy(data, w->buf + LIBUSB_CONTROL_SETUP_SIZE, n); + return true; +} + +bool UsbTransport::async_wait_progress() { + const uint64_t before = _aw_completed; + for (int turns = 0; turns < 8 && _aw_completed == before; ++turns) { + struct timeval tv {0, 250 * 1000}; + const int rc = libusb_handle_events_timeout_completed(_ctx, &tv, nullptr); + if (rc < 0) { + _logger->error("USB: event loop error {} while draining pipelined writes", + rc); + return false; + } + } + return _aw_completed != before; +} + +void UsbTransport::flush_writes() { + while (_aw_inflight > 0) { + if (async_wait_progress()) + continue; + /* Stuck queue (~2 s without a completion): cancel what is still submitted + * so the caller's next synchronous transfer is not queued behind it. */ + _logger->error("USB: pipelined write drain timed out ({} in flight)", + _aw_inflight); + for (auto *w : _aw_all) + if (w->inflight) + libusb_cancel_transfer(w->t); + /* A cancellation still completes through the callback, which is what + * clears `inflight` and returns the slot. Keep pumping for it: the count + * must never be zeroed by hand, or a late callback decrements it below + * zero (silently disabling every later drain), pushes its slot onto the + * free list a second time, and races the destructor's free. */ + for (int i = 0; i < kFlushCancelTurns && _aw_inflight > 0; ++i) + async_wait_progress(); + if (_aw_inflight > 0) { + /* The event loop itself is gone (a yanked device reports the error + * immediately, so the turns above cost nothing). Leave the slots + * submitted and off the free list; the destructor leaks them. */ + _aw_errors += _aw_inflight; + _aw_abandoned = true; + _logger->error("USB: {} pipelined transfer(s) could not be reaped; " + "their slots are retired for this session", + _aw_inflight); + } + return; + } +} + +bool UsbTransport::async_write(uint16_t wvalue, uint16_t windex, + const void *data, size_t n) { + if (n > kAsyncMaxPayload) + return false; + AsyncWrite *w = async_take_slot(); + if (!w) + return false; + libusb_fill_control_setup(w->buf, REALTEK_USB_VENQT_WRITE, 5, wvalue, windex, + static_cast(n)); + std::memcpy(w->buf + LIBUSB_CONTROL_SETUP_SIZE, data, n); + libusb_fill_control_transfer(w->t, _dev_handle, w->buf, async_write_cb, w, + USB_TIMEOUT); + const int rc = libusb_submit_transfer(w->t); + if (rc != 0) { + _aw_free.push_back(w); + _aw_errors++; + _logger->error("USB: pipelined write submit failed ({})", rc); + return false; + } + w->inflight = true; + _aw_inflight++; + return true; +} + bool UsbTransport::write_bytes(uint16_t reg_num, const uint8_t *ptr, size_t n) { + /* A vendor control transfer like any other -- counted so an InitTimer stage + * that downloads firmware this way reports what it actually spent. */ + usb_ctrl_xfers().fetch_add(1, std::memory_order_relaxed); + flush_writes(); return libusb_control_transfer(_dev_handle, REALTEK_USB_VENQT_WRITE, 5, reg_num, 0, const_cast(ptr), n, USB_TIMEOUT) == static_cast(n); @@ -854,6 +1045,7 @@ void UsbTransport::quiesce_tx() { bool UsbTransport::tx_async(uint8_t tx_ep, uint8_t *packet, size_t length, unsigned timeout_ms) { + flush_writes(); /* Reap completed async-TX transfers before submitting the next one — in the * caller's own thread, so there is no background pump to race libusb * teardown. A non-blocking handle_events (timeout 0) processes every ready @@ -1001,6 +1193,7 @@ bool UsbTransport::tx_async(uint8_t tx_ep, uint8_t *packet, size_t length, int UsbTransport::tx_sync(uint8_t ep, uint8_t *packet, size_t length, int timeout_ms) { + flush_writes(); /* No libusb_clear_halt here. rtw88_8814au's usbmon shows the first bulk * OUT is preceded by 0 CLEAR_FEATUREs; later CLEAR_FEATUREs happen during * normal TX-queue operation, not the per-send hot path. Resetting the diff --git a/src/UsbTransport.h b/src/UsbTransport.h index bbf62cea..891bd785 100644 --- a/src/UsbTransport.h +++ b/src/UsbTransport.h @@ -8,6 +8,7 @@ * that discovers the bulk endpoints. The exclusive per-adapter UsbDeviceLock * rides here too — its lifetime is the transport's. */ +#include "UsbXferCount.h" #include #include #include @@ -51,6 +52,10 @@ class UsbTransport final : public IRtlTransport { /* Realtek USB register addressing: wValue = addr[15:0], wIndex = * addr[31:16]. Lets the BB/RF window (addr + 0x10000) reach wIndex=1 * instead of colliding with the MAC/system space at wIndex=0. */ + usb_ctrl_xfers().fetch_add(1, std::memory_order_relaxed); + if (_batch) + return async_write(static_cast(addr & 0xFFFF), + static_cast(addr >> 16), &v, sizeof(v)); return libusb_control_transfer( _dev_handle, REALTEK_USB_VENQT_WRITE, 5, static_cast(addr & 0xFFFF), @@ -59,6 +64,13 @@ class UsbTransport final : public IRtlTransport { } uint32_t read32_wide(uint32_t addr) override { uint32_t data = 0; + usb_ctrl_xfers().fetch_add(1, std::memory_order_relaxed); + if (_batch) { + if (async_read(static_cast(addr & 0xFFFF), + static_cast(addr >> 16), &data, sizeof(data))) + return data; + return 0xFFFFFFFFu; + } if (libusb_control_transfer(_dev_handle, REALTEK_USB_VENQT_READ, 5, static_cast(addr & 0xFFFF), static_cast(addr >> 16), @@ -68,6 +80,9 @@ class UsbTransport final : public IRtlTransport { return 0xFFFFFFFFu; /* INVALID_RF_DATA-style sentinel on a failed read */ } bool write_bytes(uint16_t reg, const uint8_t *p, size_t n) override; + void write_batch_begin() override; + void write_batch_end() override; + void flush_writes() override; bool tx_async(uint8_t ep, uint8_t *buf, size_t len, unsigned timeout_ms) override; @@ -85,6 +100,44 @@ class UsbTransport final : public IRtlTransport { private: template T ctrl_read(uint16_t reg); template bool ctrl_write(uint16_t reg, T value); + /* Pipelined-write machinery (see IRtlTransport::write_batch_begin). */ + /* Register transfers are 1/2/4 bytes; async_write/async_read refuse a + * larger payload rather than overrun the inline setup buffer. */ + static constexpr size_t kAsyncMaxPayload = 4; + struct AsyncWrite { + libusb_transfer *t; + uint8_t buf[LIBUSB_CONTROL_SETUP_SIZE + kAsyncMaxPayload]; + UsbTransport *self; + bool done; + /* Submitted and not yet reaped: libusb owns `t` and `buf` while set, so + * the slot must not be reused, freed, or handed back to the free list. */ + bool inflight; + int status; + int actual; + }; + static constexpr int kAsyncWriteDepth = 8; + /* Extra event-loop turns spent reaping cancellations after a drain times + * out. A live loop reports each cancellation promptly; a dead one fails + * every turn immediately, so this costs nothing in the case that matters. */ + static constexpr int kFlushCancelTurns = 8; + bool async_write(uint16_t wvalue, uint16_t windex, const void *data, + size_t n); + /* Read queued behind the pending writes (EP0 order) and waited for on its + * own completion only: a read-modify-write pair costs one wakeup, not two. + * Returns false on failure (data untouched). */ + bool async_read(uint16_t wvalue, uint16_t windex, void *data, size_t n); + AsyncWrite *async_take_slot(); + bool async_wait_progress(); /* one event-loop turn; false on timeout/error */ + static void LIBUSB_CALL async_write_cb(libusb_transfer *t); + bool _batch = false; + std::vector _aw_free; + std::vector _aw_all; + int _aw_inflight = 0; + uint64_t _aw_completed = 0; + int _aw_errors = 0; + /* Set when a drain gave up with transfers still submitted: the destructor + * then leaks those slots instead of freeing a transfer libusb still owns. */ + bool _aw_abandoned = false; void discover_endpoints(); /* was InitDvObj */ const char *speed_str() const; static void transfer_callback(struct libusb_transfer *transfer); @@ -158,6 +211,13 @@ class UsbTransport final : public IRtlTransport { template T UsbTransport::ctrl_read(uint16_t reg_num) { T data = 0; + usb_ctrl_xfers().fetch_add(1, std::memory_order_relaxed); + if (_batch) { + if (async_read(reg_num, 0, &data, sizeof(T))) + return data; + _logger->error("rtw_read({:04x}) pipelined, sizeof(T) = {}", reg_num, sizeof(T)); + throw std::ios_base::failure("rtw_read"); + } if (libusb_control_transfer(_dev_handle, REALTEK_USB_VENQT_READ, 5, reg_num, 0, (uint8_t *)&data, sizeof(T), USB_TIMEOUT) == sizeof(T)) { @@ -169,6 +229,9 @@ template T UsbTransport::ctrl_read(uint16_t reg_num) { } template bool UsbTransport::ctrl_write(uint16_t reg_num, T value) { + usb_ctrl_xfers().fetch_add(1, std::memory_order_relaxed); + if (_batch) + return async_write(reg_num, 0, &value, sizeof(T)); return libusb_control_transfer(_dev_handle, REALTEK_USB_VENQT_WRITE, 5, reg_num, 0, (uint8_t *)&value, sizeof(T), USB_TIMEOUT) == sizeof(T); diff --git a/src/UsbXferCount.h b/src/UsbXferCount.h new file mode 100644 index 00000000..2a724f7f --- /dev/null +++ b/src/UsbXferCount.h @@ -0,0 +1,14 @@ +/* Process-wide count of USB vendor control transfers (register reads and + * writes). Bumped by UsbTransport; read by InitTimer so every init.timing + * stage reports how many transfers it spent, which is the unit the + * bring-up is actually paid in (each one is a synchronous EP0 round trip). */ +#pragma once +#include +#include + +namespace devourer { +inline std::atomic &usb_ctrl_xfers() { + static std::atomic n{0}; + return n; +} +} // namespace devourer diff --git a/src/jaguar3/CLAUDE.md b/src/jaguar3/CLAUDE.md index babde5bb..cbbce0c0 100644 --- a/src/jaguar3/CLAUDE.md +++ b/src/jaguar3/CLAUDE.md @@ -36,6 +36,29 @@ narrowband dividers, RF18 encoding), strategy interfaces `Jaguar3Calibration` single-path 1SS TX, spur channels, LCK, the 2.4 GHz TX kernel-parity limitation) live in `docs/8822e-quirks.md`. +## Bring-up cost and the pipelined register writes + +`InitWrite` is ~14k USB control transfers and nothing else (stage timing: +`InitTimer` events `j3hal.*` / `j3init.*`, each carrying both `ms` and the +`xfers` it spent; `bench_init.py` parses these events but reports only `ms`). +Synchronous, a transfer costs 76–80 µs on an embedded host (ssc338q) and +~27 µs pipelined 8-deep — EP0 completes URBs +in submission order, so `UsbTransport` queues writes asynchronously inside +a `write_batch_begin/end` scope and only reads (submitted behind the queue, +waited on their own completion), bulk transfers and `flush_writes` wait. +`InitWrite` runs its whole bring-up in one batch (RAII scope, ended before +the coex thread starts): 1.30 → 0.65 s warm, 2.04 → ~0.7 s cold, one +drone-side unit. **`Init` (RX-only) opens no batch yet** — not measured on a +ground-station card. +Batches are single-threaded by contract. The ms-scale settle delays +(`write_bb` 0xfc–0xfe, `rf_writer` 0xffe, `Halrf8822e::delay_ms`, the efuse +power-cut) flush first. + +The RF radio-table load is write-only: bits [31:20] of the direct window +(`0x3c00`/`0x4c00 + addr*4`) read back 0 for all 1540 entries, cold and +warm (one 8812EU unit), so the vendor's `MASK20BITS` read-modify-write +preserved nothing at the price of a synchronous read per entry. + ## TX power Both dies drive the SAME TXAGC block (`set_tx_power_ref` is the port of diff --git a/src/jaguar3/HalJaguar3.cpp b/src/jaguar3/HalJaguar3.cpp index 66abdca6..81d60301 100644 --- a/src/jaguar3/HalJaguar3.cpp +++ b/src/jaguar3/HalJaguar3.cpp @@ -1,3 +1,4 @@ +#include "InitTimer.h" #include "HalJaguar3.h" #include #include @@ -41,9 +42,12 @@ void retry_cal(Logger_t &logger, const char *what, F &&step, int tries = 3) { * write. */ void write_bb(RtlAdapter &dev, uint32_t addr, uint32_t data) { switch (addr) { - case 0xfe: std::this_thread::sleep_for(std::chrono::milliseconds(50)); return; - case 0xfd: std::this_thread::sleep_for(std::chrono::milliseconds(5)); return; - case 0xfc: std::this_thread::sleep_for(std::chrono::milliseconds(1)); return; + /* The ms-scale table delays exist to let the preceding writes settle: + * drain the pipelined-write queue before sleeping (sub-ms ones are noise + * next to the ~0.2 ms a depth-8 queue can hold). */ + case 0xfe: dev.flush_writes(); std::this_thread::sleep_for(std::chrono::milliseconds(50)); return; + case 0xfd: dev.flush_writes(); std::this_thread::sleep_for(std::chrono::milliseconds(5)); return; + case 0xfc: dev.flush_writes(); std::this_thread::sleep_for(std::chrono::milliseconds(1)); return; case 0xfb: std::this_thread::sleep_for(std::chrono::microseconds(50)); return; case 0xfa: std::this_thread::sleep_for(std::chrono::microseconds(5)); return; case 0xf9: std::this_thread::sleep_for(std::chrono::microseconds(1)); return; @@ -87,11 +91,16 @@ void HalJaguar3::run_iqk(SelectedChannel channel) { * Every step is ported from vendor source. */ void HalJaguar3::rtw_hal_init(SelectedChannel channel) { ChannelWidth_t bw = channel.ChannelWidth; + InitTimer timer(_logger, "j3hal"); _macinit.pre_init_system_cfg(); + timer.stage("pre_init_system_cfg"); power_on(); /* mac_power_switch(on) — card_en_flow */ + timer.stage("power_on"); read_chip_version(); /* needs MAC alive; supplies cut for system_cfg */ + timer.stage("chip_version"); cache_efuse_8822e(); /* one-shot OTP decode while access is reliable */ + timer.stage("efuse_cache"); if (_variant == ChipVariant::C8822E && _efuse_cache_valid) /* Efuse thermal baseline (0xd0/0xd1) + channel for pwr_track thermal tracking. */ _cal->set_pwr_track_ctx(_efuse_cache[0xd0], _efuse_cache[0xd1], @@ -109,35 +118,46 @@ void HalJaguar3::rtw_hal_init(SelectedChannel channel) { (_phy_ctx.rfe_type == 0 || _phy_ctx.rfe_type == 0xff)) _phy_ctx.rfe_type = 21; _logger->info("Jaguar3: rfe_type=0x{:02x}", _phy_ctx.rfe_type); + timer.stage("efuse_rfe"); _macinit.init_system_cfg(bw, _ver.cut); + timer.stage("init_system_cfg"); if (!_fw.download_default_firmware()) { _logger->error("Jaguar3: firmware download FAILED (structured)"); return; } _logger->info("Jaguar3: firmware booted (structured)"); + timer.stage("dlfw"); if (!_macinit.init_mac_cfg(bw)) { _logger->error("Jaguar3: init_mac_cfg FAILED (structured)"); return; } + timer.stage("init_mac_cfg"); /* Propagate the queue-init reserved-page boundary to the FW downloader — it * was 0 during the pre-queue-init FW stage, so a beacon rsvd-page download * would otherwise target page 0 instead of the real boundary. */ _fw.set_rsvd_boundary(_macinit.rsvd_boundary()); _macinit.init_usb_cfg(); /* USB RX-DMA mode — RX delivery to bulk-IN */ _macinit.enable_bb_rf(true); /* set_hw_value(EN_BB_RF) */ - apply_bb_rf_agc_tables(); /* init_phy: BB + AGC + RF tables */ + timer.stage("usb_cfg_bbrf"); + apply_bb_rf_agc_tables(&timer); /* init_phy: BB + AGC + RF tables */ config_pa_bias_8822e(); /* kfree: efuse PA-bias trim -> RF 0x60 */ + timer.stage("pa_bias"); config_phydm_parameter_init(); /* POST_SETTING: 3-wire + OFDM/CCK block + bb-reset */ + timer.stage("phydm_param_init"); init_rfk(); /* RF cal_init (0x1B00); IQK runs via run_iqk */ + timer.stage("rfk_init"); /* halrf DACK — DAC cal before IQK. Retry-wrapped: its status-poll loops issue * tens of thousands of USB reads and an intermittent glitch was aborting * bring-up (rtw_read iostream error, seen on 8822EU). */ retry_cal(_logger, "DACK", [this] { _cal->dac_calibrate(); }); + timer.stage("dack"); bf_init(); /* rtl8822c_phy_bf_init (rtl8822c_halinit.c) */ monitor_rx_cfg(); /* devourer monitor-mode RX enable */ enable_tx_path(); /* enable OFDM/CCK TX block (gates on-air TX) */ + timer.stage("bf_rx_tx_cfg"); + timer.total(); _logger->info("Jaguar3: bring-up complete"); } @@ -529,6 +549,7 @@ void HalJaguar3::efuse_pwr_cut_8822e(bool on) { if (on) { _device.rtw_write8(PMC, static_cast(_device.rtw_read8(PMC) | kWrMsk)); _device.rtw_write16(SYS_ISO, static_cast(_device.rtw_read16(SYS_ISO) | kPwcS)); + _device.flush_writes(); std::this_thread::sleep_for(std::chrono::milliseconds(1)); _device.rtw_write16(SYS_ISO, static_cast(_device.rtw_read16(SYS_ISO) | kPwcB)); _device.rtw_write16(SYS_ISO, static_cast(_device.rtw_read16(SYS_ISO) & ~kEbCore)); @@ -541,6 +562,7 @@ void HalJaguar3::efuse_pwr_cut_8822e(bool on) { _device.rtw_write32(EFC1, _device.rtw_read32(EFC1) & ~kBurst); _device.rtw_write16(SYS_ISO, static_cast(_device.rtw_read16(SYS_ISO) | kEbCore)); _device.rtw_write16(SYS_ISO, static_cast(_device.rtw_read16(SYS_ISO) & ~kPwcB)); + _device.flush_writes(); std::this_thread::sleep_for(std::chrono::milliseconds(1)); _device.rtw_write16(SYS_ISO, static_cast(_device.rtw_read16(SYS_ISO) & ~kPwcS)); _device.rtw_write8(PMC, static_cast(_device.rtw_read8(PMC) & ~kWrMsk)); @@ -951,7 +973,7 @@ void HalJaguar3::power_on() { _logger->info("Jaguar3: power-on sequence complete (card active)"); } -void HalJaguar3::apply_bb_rf_agc_tables() { +void HalJaguar3::apply_bb_rf_agc_tables(InitTimer *timer) { /* BB + AGC baseline via the validated halbb walker (src/jaguar3/ * PhyTableLoaderJaguar3). _phy_ctx must be populated from the chip-version + * EFUSE read (done earlier in rtw_hal_init) before the tables are walked. */ @@ -962,8 +984,10 @@ void HalJaguar3::apply_bb_rf_agc_tables() { const auto agc_tab = _tables->agc_tab(); _logger->info("Jaguar3: applying BB phy_reg table ({} words)", phy_reg.len); PhyTableLoaderJaguar3::Load(phy_reg.data, phy_reg.len, _phy_ctx, bb); + if (timer) timer->stage("table_phy_reg"); _logger->info("Jaguar3: applying AGC table ({} words)", agc_tab.len); PhyTableLoaderJaguar3::Load(agc_tab.data, agc_tab.len, _phy_ctx, bb); + if (timer) timer->stage("table_agc"); /* RF radio tables. On 8822C an RF register write is a direct BB write to a * per-path window: BB[base + (rf_addr&0xff)*4], 20-bit mask, base 0x3c00 @@ -973,7 +997,7 @@ void HalJaguar3::apply_bb_rf_agc_tables() { auto rf_writer = [this](uint16_t base) { return [this, base](uint32_t addr, uint32_t data) { switch (addr) { - case 0xffe: std::this_thread::sleep_for(std::chrono::milliseconds(50)); return; + case 0xffe: _device.flush_writes(); std::this_thread::sleep_for(std::chrono::milliseconds(50)); return; case 0xfe: std::this_thread::sleep_for(std::chrono::microseconds(100)); return; case 0xffff: std::this_thread::sleep_for(std::chrono::microseconds(1)); return; case 0x0: @@ -986,8 +1010,15 @@ void HalJaguar3::apply_bb_rf_agc_tables() { _device.rtw_write32(base == 0x4c00 ? 0x4108 : 0x1808, data & RFREG_MASK); return; default: - _device.phy_set_bb_reg(static_cast(base + ((addr & 0xff) << 2)), - RFREG_MASK, data); + /* Plain 20-bit write, not the vendor's read-modify-write under + * MASK20BITS: the direct window's bits [31:20] read back 0 for every + * one of the 1540 table entries, cold boot and warm restart alike + * (histogrammed on one 8812EU unit), so preserving them is a + * no-op that cost a synchronous read per entry -- half the RF table + * stage, ~80 ms on the ssc338q. Write-only also pipelines + * (IRtlTransport::write_batch_begin). */ + _device.rtw_write32(static_cast(base + ((addr & 0xff) << 2)), + data & RFREG_MASK); } }; }; @@ -1003,6 +1034,7 @@ void HalJaguar3::apply_bb_rf_agc_tables() { _device.phy_set_bb_reg(0x1c90, 1u << 8, 1); _device.phy_set_bb_reg(0x1830, 1u << 29, 1); _device.phy_set_bb_reg(0x4130, 1u << 29, 1); + if (timer) timer->stage("table_rf_ab"); _logger->info("Jaguar3: BB/AGC/RF tables applied"); } diff --git a/src/jaguar3/HalJaguar3.h b/src/jaguar3/HalJaguar3.h index 505a7b2d..949d2f19 100644 --- a/src/jaguar3/HalJaguar3.h +++ b/src/jaguar3/HalJaguar3.h @@ -99,7 +99,7 @@ class HalJaguar3 { void power_off(); /* card-disable PWR_SEQ — reset from active state */ void power_on(); /* card-enable PWR_SEQ */ void init_rfk(); /* RF-calibration init (0x1B00 cal_init block) */ - void apply_bb_rf_agc_tables(); /* phydm BB/AGC/RF tables via PhyTableLoader */ + void apply_bb_rf_agc_tables(class InitTimer *timer = nullptr); /* phydm BB/AGC/RF tables via PhyTableLoader */ void bf_init(); /* rtl8822c_phy_bf_init: BF/MU + NDPA sounding */ void config_phydm_parameter_init(); /* POST_SETTING: 3-wire + OFDM/CCK block */ void enable_tx_path(); /* OFDM/CCK TX block + AGC/path enable (on-air TX) */ diff --git a/src/jaguar3/Halrf8822e.cpp b/src/jaguar3/Halrf8822e.cpp index b1c08b6f..67e34184 100644 --- a/src/jaguar3/Halrf8822e.cpp +++ b/src/jaguar3/Halrf8822e.cpp @@ -87,6 +87,7 @@ void Halrf8822e::delay_us(uint32_t us) { std::this_thread::sleep_for(std::chrono::microseconds(us)); } void Halrf8822e::delay_ms(uint32_t ms) { + _device.flush_writes(); /* the settle time must follow the writes */ std::this_thread::sleep_for(std::chrono::milliseconds(ms)); } diff --git a/src/jaguar3/Halrf8822e.h b/src/jaguar3/Halrf8822e.h index 7b5c2807..45c3c14d 100644 --- a/src/jaguar3/Halrf8822e.h +++ b/src/jaguar3/Halrf8822e.h @@ -53,7 +53,7 @@ class Halrf8822e : public Jaguar3Calibration { uint32_t rf_read(uint8_t path, uint16_t addr, uint32_t mask); void rf_write(uint8_t path, uint16_t addr, uint32_t mask, uint32_t val); static void delay_us(uint32_t us); - static void delay_ms(uint32_t ms); + void delay_ms(uint32_t ms); /* drains pipelined writes first */ /* --- DAC calibration (port of halrf_dac_cal_8822e / halrf_8822e.c) --- * 8822e DACK uses the AFE S0/S1 banks (0x3800/0x3900) rather than 8822c's BB diff --git a/src/jaguar3/RtlJaguar3Device.cpp b/src/jaguar3/RtlJaguar3Device.cpp index 77c70b51..04f65b77 100644 --- a/src/jaguar3/RtlJaguar3Device.cpp +++ b/src/jaguar3/RtlJaguar3Device.cpp @@ -1,3 +1,4 @@ +#include "InitTimer.h" #include "RtlJaguar3Device.h" #include @@ -54,10 +55,23 @@ RtlJaguar3Device::RtlJaguar3Device(RtlAdapter device, Logger_t logger, variant == jaguar3::ChipVariant::C8822E ? "8822E/EU" : "8822C/CU"); } +/* Pipelined register writes for the whole bring-up (IRtlTransport:: + * write_batch_begin): ends on scope exit so a throw never leaves the + * transport in batch mode for the threads that start afterwards. */ +struct WriteBatchScope { + RtlAdapter &dev; + explicit WriteBatchScope(RtlAdapter &d) : dev(d) { dev.write_batch_begin(); } + void end() { dev.write_batch_end(); } + ~WriteBatchScope() { dev.write_batch_end(); } +}; + void RtlJaguar3Device::Init(Action_ParsedRadioPacket packetProcessor, SelectedChannel channel) { _channel = channel; _rx_wanted = true; + /* No WriteBatchScope here (yet): the pipelined bring-up is validated on + * the TX path (InitWrite, cold + warm); the RX-only + * Init path has not been measured with it on a ground-station card. */ _hal.rtw_hal_init(channel); /* full vendor-source bring-up */ /* Tune the channel/bandwidth (5/10 MHz ChannelWidth re-clocks to narrowband), * then run IQK calibration (it reads RF18 for the tuned channel). @@ -711,7 +725,10 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { * race the running TX). */ const bool want_rx = _cfg.rx.enable_with_tx; _rx_wanted = want_rx; + InitTimer timer(_logger, "j3init"); + WriteBatchScope batch(_device); _hal.rtw_hal_init(channel); /* full vendor-source bring-up */ + timer.stage("hal_init"); /* 8822C at 40/80 MHz: IQK at 20 MHz, then retune — see Init. */ const bool iqk_at_20 = _variant == jaguar3::ChipVariant::C8822C && (channel.ChannelWidth == CHANNEL_WIDTH_40 || @@ -722,7 +739,9 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { SelectedChannel iqk_ch = channel; if (iqk_at_20) iqk_ch.ChannelWidth = CHANNEL_WIDTH_20; /* IQK command set follows the RF */ + timer.stage("set_channel"); _hal.run_iqk(iqk_ch); + timer.stage("iqk"); if (iqk_at_20) _radioManagement.set_channel_bwmode(channel.Channel, channel.ChannelOffset, channel.ChannelWidth); @@ -731,6 +750,7 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { _hal.dpk_force_bypass_8822e(); /* 8822e rfe 21/22: kernel bypasses DPK (after IQK) */ _hal.config_rfe(channel.Channel); /* 8822e RFE/PAPE antenna-switch pins (PA enable) */ _hal.config_channel_8822e(channel.Channel); /* 8822e band TX scaling/backoff + shaping */ + timer.stage("rx_path_rfe_channel"); /* DEVOURER_CW_TONE — a bare RF LO carrier. Armed HERE (before the FW power-mode * / coex H2C steps below, which on the 8812EU at 5 GHz leave the chip NAKing @@ -777,10 +797,12 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { * re-apply at the end of this function; this early one just keeps the * intermediate bring-up steps on sane references. */ apply_tx_power_current(/*full=*/true); + timer.stage("txpower_pre"); _brought_up = true; /* WiFi-only coex bring-up: disable the BT/LTE antenna arbitration and lock the * antenna to WLAN so on-air TX is not killed by the coex firmware. */ _hal.coex_wlan_only_init(); + timer.stage("coex_wlan_only_init"); /* RFE GPIO/pad pinmux — the HalMAC "Config PIN Mux" (halmac_init_8822e) that * devourer's hand-rolled MAC init skips: route + drive the RFE PA-enable / * antenna-switch control pins. Without it the 8822e's PA pins are never driven @@ -801,9 +823,13 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { _device.rtw_write(0x0064, v64 & ~0x02040000u); /* PAD_CTRL1: RFE pads */ } + timer.stage("rfe_pinmux"); _hal.fw_set_pwr_mode_active(); /* keep all FW power domains on (no auto-PS) */ + timer.stage("fw_pwr_mode"); _hal.fw_coex_query_bt_info(); /* make the FW confirm BT is absent */ + timer.stage("fw_coex_query"); _hal.fw_coex_tdma_off(); /* disable coex time-division (WL keeps antenna) */ + timer.stage("fw_coex_tdma_off"); /* DEVOURER_BF_ARM_SOUNDER=1 — beamforming self-sounding probe (beamformer * side): arm the MAC's hardware sounding engine so a TX-descriptor-marked * NDPA (DEVOURER_TX_NDPA=1) is followed by a hardware-generated NDP. The MAC @@ -868,6 +894,7 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { * Applied before the coex thread starts so the writes don't contend. */ if (_cfg.tuning.disable_cca) SetCcaMode(true); + timer.stage("filters_cca"); /* DEVOURER_XTAL_CAP — crystal-cap trim (issue #217); before the coex thread * so the AFE write doesn't contend with the periodic coex re-apply. */ if (_cfg.tuning.xtal_cap) @@ -878,6 +905,7 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { * TX-power state (flat override / offset) only sticks when applied after * them. The coex thread's ~2 s ticks do not rewrite the refs. */ apply_tx_power_current(/*full=*/true); + timer.stage("txpower_post"); /* Per-packet power banks: the BB init table reset 0x1e70 (0x00001000, all * banks disabled) and may have cleared the per-STA RAM — re-sync the * hardware to the planner state (a pre-bring-up SetTxPacketPowerOffsetQdb @@ -907,6 +935,8 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { _device.rtw_read32(a), _device.rtw_read32(a + 4), _device.rtw_read32(a + 8), _device.rtw_read32(a + 12)); } + timer.stage("dpdt_ack_misc"); + batch.end(); /* sync writes from here: the coex thread shares the transport */ _coex_thread = std::thread([this] { coex_runtime_loop(); }); if (_cfg.rx.ack_responder && !SetAckResponder(*_cfg.rx.ack_responder)) /* DEVOURER_ACK_RESPONDER */ @@ -914,6 +944,8 @@ void RtlJaguar3Device::InitWrite(SelectedChannel channel) { "Jaguar3: configured ACK responder could not be armed"); if (_cfg.tx.ampdu) SetAmpduMode(*_cfg.tx.ampdu); /* DEVOURER_TX_AMPDU_MODE */ + timer.stage("coex_thread_ampdu"); + timer.total(); _logger->info("Jaguar3: ready for TX (monitor inject)"); }