summaryrefslogtreecommitdiff
path: root/src/common/tusb_sysview.c
blob: a507a73d5ad11d4d078b173f7ada33d94ff9fd7f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
/*
 * SPDX-FileCopyrightText: Copyright (c) 2019 Ha Thach (tinyusb.org)
 * SPDX-License-Identifier: MIT
 *
 * This file is part of the TinyUSB stack.
 */

#include "tusb_option.h"

#if CFG_TUD_SYSVIEW || CFG_TUH_SYSVIEW

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#include "common/tusb_common.h"     // TU_ARRAY_SIZE
#include "common/tusb_sysview.h"

#if CFG_TUSB_OS == OPT_OS_FREERTOS
#include "FreeRTOS.h"
#include "task.h"
extern const SEGGER_SYSVIEW_OS_API SYSVIEW_X_OS_TraceAPI;
#define SYSVIEW_OS_API  (&SYSVIEW_X_OS_TraceAPI)
#define SYSVIEW_OS_DESC ",O=FreeRTOS"
#else
#define SYSVIEW_OS_API  0
#define SYSVIEW_OS_DESC ""
#endif

#ifndef SYSVIEW_APP_NAME
#define SYSVIEW_APP_NAME    "TinyUSB"
#endif
#ifndef SYSVIEW_DEVICE_NAME
#define SYSVIEW_DEVICE_NAME "Cortex-M"
#endif
#ifndef SYSVIEW_RAM_BASE
#define SYSVIEW_RAM_BASE    (0x20000000)
#endif



// CPU clock reported to SystemView (and, on DWT cores, the timestamp frequency). SystemCoreClock
// is a CMSIS global that non-CMSIS SDKs do not have -- the Pico SDK for one -- so route it through
// a weak accessor those BSPs override (hw/bsp/rp2040/family.c). Unused weak definitions are
// dropped by --gc-sections, taking the SystemCoreClock reference with them.
// Most targets' vendor CMSIS/device header already declares SystemCoreClock, but there is no
// single header this file can include to get it across vendors, so declare it and silence the
// duplicate for this line alone. Two narrower attempts do NOT work: gating on
// CFG_TUSB_OS != OPT_OS_FREERTOS (bare-metal mimxrt1064_evk pulls NXP's header too, and failed
// to build at SYSVIEW=4), and moving the extern to block scope (GCC still reports it redundant).
#if defined(__GNUC__)
  #pragma GCC diagnostic push
  #pragma GCC diagnostic ignored "-Wredundant-decls"
#endif
extern uint32_t SystemCoreClock;
#if defined(__GNUC__)
  #pragma GCC diagnostic pop
#endif

TU_ATTR_WEAK uint32_t tusb_sysview_cpu_freq(void) {
  return SystemCoreClock;
}

// Kernel objects named for SystemView: sent immediately AND kept, so the names
// survive a recorder that attaches later (SEGGER only re-runs the
// system-description callback on connect). Names must be string literals or
// otherwise static -- only the pointer is kept.
#define TU_SV_NAMED_MAX 4
static struct {
  const void* handle;
  const char* name;
} _sv_named[TU_SV_NAMED_MAX];
static uint8_t _sv_named_count;

void tusb_sysview_name_resource(const void* handle, const char* name) {
  if (handle == NULL || name == NULL) {
    return; // silently ignored: naming is cosmetic, never worth failing a boot over
  }
  // Already registered -- e.g. tusb_deinit() + re-init (examples/dual/dynamic_switch's role
  // switch) re-registers the same static mutex handle every time. Update the name in place
  // instead of appending a duplicate: with TU_SV_NAMED_MAX this small, a few duplicates are
  // enough to permanently push out every other handle's name.
  for (uint8_t i = 0; i < _sv_named_count; i++) {
    if (_sv_named[i].handle == handle) {
      _sv_named[i].name = name;
      SEGGER_SYSVIEW_NameResource((U32) (uintptr_t) handle, name);
      return;
    }
  }
  if (_sv_named_count >= TU_SV_NAMED_MAX) {
    return; // table full and this is a genuinely new handle: drop it, cosmetic only
  }
  _sv_named[_sv_named_count].handle = handle;
  _sv_named[_sv_named_count].name = name;
  _sv_named_count++;
  // Send it now as well: tusb_rhport_init() runs tusb_sysview_init() -- and with it
  // SEGGER_SYSVIEW_Start()'s one-shot system-description callback -- BEFORE the stacks
  // create their mutexes, so registration always lands after that emission. The stored
  // copy exists for the replay on a later recorder connect, not for this first one.
  SEGGER_SYSVIEW_NameResource((U32) (uintptr_t) handle, name);
}

static void send_sys_desc(void) {
  SEGGER_SYSVIEW_SendSysDesc("N=" SYSVIEW_APP_NAME ",D=" SYSVIEW_DEVICE_NAME SYSVIEW_OS_DESC);
  SEGGER_SYSVIEW_SendSysDesc("I#15=SysTick");
  for (uint8_t i = 0; i < _sv_named_count; i++) {
    SEGGER_SYSVIEW_NameResource((U32) (uintptr_t) _sv_named[i].handle, _sv_named[i].name);
  }
}

#if defined(CFG_TUSB_SYSVIEW_TIMESTAMP_BSP) && CFG_TUSB_SYSVIEW_TIMESTAMP_BSP
  // ARMv7-M part whose DWT lacks CYCCNT (MAX32665/6: DWT_CTRL.NOCYCCNT reads 1 — a part like
  // this LINKS fine, so the fails-to-link signal below never fires; the symptom is every
  // duration silently decoding as 0). Its family.cmake builds SystemView with
  // SEGGER_SYSVIEW_CORE_OTHER so SEGGER calls SEGGER_SYSVIEW_X_GetTimestamp(), and — because
  // the fixed-1MHz microsecond contract below may be unreachable (MAX32 prescalers are
  // powers-of-two only) — the BSP reports the counter's rate as well:
  //
  //     uint32_t SEGGER_SYSVIEW_X_GetTimestamp(void);       // free-running counter
  //     uint32_t SEGGER_SYSVIEW_X_GetTimestampFreq(void);   // its rate, Hz
  extern uint32_t SEGGER_SYSVIEW_X_GetTimestampFreq(void);
  #define SYSVIEW_TIMESTAMP_FREQ SEGGER_SYSVIEW_X_GetTimestampFreq()
#elif !defined(SEGGER_SYSVIEW_CORE) || (SEGGER_SYSVIEW_CORE != SEGGER_SYSVIEW_CORE_CM3)
  // No DWT cycle counter on this core (ARMv6-M M0/M0+, RISC-V, ...), so the BSP must supply the
  // timestamp from a free-running hardware timer. Contract for hw/bsp/<family>/family.c:
  //
  //     uint32_t SEGGER_SYSVIEW_X_GetTimestamp(void);   // free-running, MICROSECONDS
  //
  // Microseconds (not core cycles) keeps one fixed frequency for every such family, so nothing
  // else has to be told which timer was used. A family without an implementation fails to link
  // naming this symbol — that is the intended "SYSVIEW not ported to this family yet" signal.
  #define SYSVIEW_TIMESTAMP_FREQ 1000000u
#else
  // DWT cycle counter: SEGGER_SYSVIEW_ConfDefaults.h reads it inline, no BSP code needed.
  #define SYSVIEW_TIMESTAMP_FREQ tusb_sysview_cpu_freq()
#endif

/* Active-interrupt id. ConfDefaults reads it from the Cortex-M ICSR itself on
 * both CM3 (bits [8:0]) and CM0 (bits [5:0]); only cores it doesn't know
 * (RISC-V here) need this hook. On RISC-V the analogous "what am I currently
 * servicing" register is mcause, whose low bits carry the trap/interrupt code
 * while an ISR runs. */
#if !defined(SEGGER_SYSVIEW_CORE) || \
    ((SEGGER_SYSVIEW_CORE != SEGGER_SYSVIEW_CORE_CM3) && (SEGGER_SYSVIEW_CORE != SEGGER_SYSVIEW_CORE_CM0))
U32 SEGGER_SYSVIEW_X_GetInterruptId(void) {
  #if defined(__riscv) || defined(__riscv__)
  uint32_t mcause;
  __asm volatile ("csrr %0, mcause" : "=r" (mcause));
  return (U32) (mcause & 0xFFFu);
  #elif defined(__ARM_ARCH_7M__) || defined(__ARM_ARCH_7EM__) || defined(__ARM_ARCH_8M_MAIN__) || \
        defined(__ARM_ARCH_8_1M_MAIN__) || defined(__ARM_ARCH_6M__) || defined(__ARM_ARCH_8M_BASE__)
  // A Cortex-M built as SEGGER_SYSVIEW_CORE_OTHER (CFG_TUSB_SYSVIEW_TIMESTAMP_BSP parts,
  // e.g. MAX3266x) lands here: same ICSR.VECTACTIVE read ConfDefaults would have inlined.
  // 9 bits on v7-M/v8-M-mainline, 6 on v6-M — 0x1FF covers both (upper bits read 0 there).
  return (*(volatile uint32_t*) 0xE000ED04u) & 0x1FFu;
  #else
  return 0; // unknown core: report "no interrupt" rather than a bogus id
  #endif
}
#endif

// Depth-counted ISR enter/exit (tusb_sysview.h's TU_SYSVIEW_ISR_ENTER/_EXIT): ten dual-role
// BSPs call tud_int_handler()+tuh_int_handler() back-to-back from ONE hardware ISR, each of
// which self-wraps -- so one real interrupt would otherwise emit ENTER,EXIT,ENTER,EXIT to
// SEGGER's recorder (double activation count, each span timed at roughly half its true
// duration). This counter only collapses NESTED enter/exit pairs, though -- two back-to-back
// (non-nested) pairs still emit twice on their own. The actual fix is two-part: each of those
// ten BSPs' vector ISR (hw/bsp/<family>/family.c) wraps its whole tud_+tuh_int_handler() body in
// one more outer TU_SYSVIEW_ISR_ENTER()/_EXIT() pair, which nests the two inner (self-wrapped)
// calls inside it -- THIS counter is what then collapses that nesting down to a single
// ENTER/EXIT. Only the outermost 0->1 transition records ENTER, matching the 1->0 transition
// that records EXIT.
//
// volatile because it is written from ISR context, and on parts whose USB IRQs nest (ch32v20x
// documents HP preempting LP) two different ISRs can reach it. The increment/decrement stay
// non-atomic: a lost update miscounts one span and self-corrects on the next balanced pair
// rather than wedging, which is not worth a critical section on every ISR entry.
static volatile uint8_t _sv_isr_depth;

void tusb_sysview_isr_enter(void) {
  if (_sv_isr_depth++ == 0) {
    SEGGER_SYSVIEW_RecordEnterISR();
  }
}

// W4: SCB->ICSR.PENDSVSET (Cortex-M0/M3+ only -- see the SEGGER_SYSVIEW_CORE guard below) tells
// us, at the moment the outermost ISR span ends, whether a context switch was requested while it
// ran (e.g. a FreeRTOS ISR-context give/notify that pends PendSV). If it was, this interrupt will
// NOT return to the task it preempted -- it falls through to PendSV and the scheduler picks the
// next task, possibly a different one. RecordExitISRToScheduler() is the record that tells
// SystemView that story; the plain RecordExitISR() below claims the interrupted task resumes
// directly, which is wrong whenever PendSV is pending -- every FreeRTOS capture showed the USB
// ISR "returning" to the interrupted task, immediately followed by a causeless switch. RISC-V
// (SEGGER_SYSVIEW_CORE_OTHER here) has no PendSV analogue and no ICSR, so it always takes the
// plain exit record.
#if defined(SEGGER_SYSVIEW_CORE) && \
    ((SEGGER_SYSVIEW_CORE == SEGGER_SYSVIEW_CORE_CM3) || (SEGGER_SYSVIEW_CORE == SEGGER_SYSVIEW_CORE_CM0))
  #define SV_ICSR_PENDSVSET_PENDING() \
    ((*(volatile uint32_t*) 0xE000ED04 /* SCB->ICSR */) & (1u << 28) /* PENDSVSET */)
#endif

void tusb_sysview_isr_exit(void) {
  if (_sv_isr_depth != 0 && --_sv_isr_depth == 0) {
#if defined(SV_ICSR_PENDSVSET_PENDING)
    if (SV_ICSR_PENDSVSET_PENDING()) {
      SEGGER_SYSVIEW_RecordExitISRToScheduler();
    } else {
      SEGGER_SYSVIEW_RecordExitISR();
    }
#else
    SEGGER_SYSVIEW_RecordExitISR();
#endif
  }
}

void tusb_sysview_init(void) {
  static bool inited = false;
  if (inited) { return; }
  inited = true;
#if defined(SEGGER_SYSVIEW_CORE) && (SEGGER_SYSVIEW_CORE == SEGGER_SYSVIEW_CORE_CM3)
  /* DWT cycle counter drives timestamps; enable on-target so recording never
   * depends on a debugger-side enable (also needed post-mortem). CYCCNT is
   * optional even on ARMv7-M: DWT_CTRL.NOCYCCNT (bit 25) reads 1 when the
   * counter is absent, in which case enabling it is a no-op and timestamps
   * would stall — check first, as SEGGER's own sample config does. */
  (*(volatile unsigned int*) 0xE000EDFC) |= (1u << 24);   /* DEMCR.TRCENA */
  if (((*(volatile unsigned int*) 0xE0001000) & (1u << 25)) == 0) {
    (*(volatile unsigned int*) 0xE0001000) |= 1u;         /* DWT_CTRL.CYCCNTENA */
  }
#endif
  SEGGER_SYSVIEW_Init(SYSVIEW_TIMESTAMP_FREQ, tusb_sysview_cpu_freq(), SYSVIEW_OS_API, send_sys_desc);
  SEGGER_SYSVIEW_SetRAMBase(SYSVIEW_RAM_BASE);
  /* Deliberately no SEGGER_SYSVIEW_RegisterModule(): SystemView 4.10b (Linux)
   * greys out File > Save Recording / Export Data as soon as ANY module is
   * registered, regardless of module content — bench-proven with 5 module
   * configurations. Function-timing
   * events are recorded at the fixed TU_SV_EVENT_BASE offset instead
   * (tusb_sysview.h) so host-side export keeps working. */
  SEGGER_SYSVIEW_Start(); /* self-start: host recorders only drain */
#ifdef SYSVIEW_DISABLE_EVENTS
  SEGGER_SYSVIEW_DisableEvents(SYSVIEW_DISABLE_EVENTS);
#endif
}

// pHeap is a handle identifying the heap, not the block being (de)allocated -- it must stay the
// same across every Define/Alloc/Free call or SystemView cannot associate them. A stable dummy
// object's address serves that purpose (its own bytes are never touched); the actual base is
// unknowable from here regardless (events still carry the real ptr+size).
static uint8_t _sv_heap_handle;

void tusb_sysview_heap_alloc(void* ptr, unsigned size) {
  // _SendPacket() (SystemView's SEGGER_SYSVIEW.c) reads SEGGER_SYSVIEW_GET_TIMESTAMP() before
  // it checks EnableState, so a call reaching SEGGER_SYSVIEW_HeapAlloc() below before
  // tusb_sysview_init() has run -- e.g. FreeRTOS's own early heap allocations -- can read a
  // still clock-gated timer (TIM2 on stm32f0/g0/u0, CT32B0 on lpc11): a bus fault. Guarded here
  // (mirrored on the free side below) instead of relying on every caller to have run
  // board_init()/tusb_sysview_init() first.
  if (!SEGGER_SYSVIEW_IsStarted()) { return; }
  static bool heap_defined = false;
  // heap_defined only ever latches true below, on a build that CAN define the heap (FreeRTOS
  // with a known configTOTAL_HEAP_SIZE); every other build leaves it false forever, so this
  // block harmlessly re-checks (and re-skips) the #if on every call -- same as before the
  // IsStarted() guard above existed, just now only reached once SystemView has actually
  // started.
  if (!heap_defined) {
#if CFG_TUSB_OS == OPT_OS_FREERTOS && defined(configTOTAL_HEAP_SIZE) && (configTOTAL_HEAP_SIZE > 0)
    // Heap capacity is the FreeRTOS heap_4/heap_5 arena size -- NOT this first allocation's own
    // size, which used to be passed here and made SystemView's "used/free" bookkeeping wrong
    // for every allocation after the first.
    SEGGER_SYSVIEW_HeapDefine(&_sv_heap_handle, &_sv_heap_handle, configTOTAL_HEAP_SIZE, 0);
    heap_defined = true;
#endif
    // else: no known capacity (heap_3, a non-FreeRTOS OS, or configTOTAL_HEAP_SIZE undefined) --
    // omit the define rather than guess; SEGGER_SYSVIEW_HeapAlloc() below still records the
    // event, SystemView just can't show a used/free percentage for it.
  }
  SEGGER_SYSVIEW_HeapAlloc(&_sv_heap_handle, ptr, size);
}

void tusb_sysview_heap_free(void* ptr) {
  if (!SEGGER_SYSVIEW_IsStarted()) { return; } // mirrors the alloc side's guard above
  SEGGER_SYSVIEW_HeapFree(&_sv_heap_handle, ptr);
}

void tusb_sysview_stack_report(void) {
#if CFG_TUSB_OS == OPT_OS_FREERTOS && (configUSE_TRACE_FACILITY == 1)
  // Same cap SEGGER's own FreeRTOS table uses (hw/bsp/family_support.cmake's
  // SYSVIEW_FREERTOS_MAX_NOF_TASKS) -- one number instead of two that can drift apart.
  // uxTaskGetSystemState() returns 0 -- not a truncated list -- when status[] is smaller than
  // the actual task count, silently emptying this table above the cap; report nothing rather
  // than guess. Raise SYSVIEW_FREERTOS_MAX_NOF_TASKS if that ceiling is hit.
  #ifndef SYSVIEW_FREERTOS_MAX_NOF_TASKS
  #define SYSVIEW_FREERTOS_MAX_NOF_TASKS 16
  #endif
  /* static, not a local array: sizeof(TaskStatus_t)*16 = 704 bytes is a large
   * fraction of a small task's stack (e.g. usbd's default 1024-byte stack).
   * An automatic array here was one contributor to a real stack-overflow
   * HardFault during USB enumeration on hardware (root-caused on same54_xplained:
   * default 1024-byte usbd/cdc FreeRTOS task stacks are marginal for a
   * SYSVIEW=4 build in general -- removing just this array was not sufficient
   * by itself; see the paired USBD_STACK_SIZE/CDC_STACK_SIZE bump in
   * examples/device/cdc_msc_freertos/src/main.c, which is the change that
   * actually restores enumeration). Moving it to .bss costs the same RAM but
   * none of the caller's stack, which is still worth doing on its own merits.
   * Single writer (usbd's periodic report, or usbh's in a host-only build --
   * never both, never reentered), so no locking is needed. */
  static TaskStatus_t status[SYSVIEW_FREERTOS_MAX_NOF_TASKS];
  /* pcTaskName points into the live TCB, which a task deleted later in the lap frees; copy the
   * name at snapshot time so SendTaskInfo() below never dereferences a dead TCB. */
  static char names[SYSVIEW_FREERTOS_MAX_NOF_TASKS][configMAX_TASK_NAME_LEN];
  /* Report one task per call instead of looping over all of them: even with
   * the array off the stack, up to SYSVIEW_FREERTOS_MAX_NOF_TASKS back-to-back
   * SEGGER_SYSVIEW_SendTaskInfo() calls (each locking + writing the RTT ring buffer) in a
   * single burst is still enough cumulative time inside this task-context call to disturb the
   * USB peripheral on hardware -- a same54_xplained
   * cdc_msc_freertos SYSVIEW=4 build enumerated fine but then reproducibly
   * dropped off the bus tens of seconds later, exactly when this call first
   * fired; disabling the call site entirely made that disappear (100 s
   * stable vs. a consistent ~30-45 s failure), isolating it to this burst.
   * Spreading the SendTaskInfo() calls across separate invocations (still one
   * every 1024 tud_task_ext events) keeps each invocation short. */
  static UBaseType_t next_idx = 0;
  // W12: uxTaskGetSystemState() suspends the scheduler for a byte-scan of every task's stack --
  // real time, even though this function only ever publishes ONE task per call. Cache its
  // result and refresh only when the rotation wraps back to index 0 (once per full lap over the
  // task list, not every call): same set of Stack Info events published, over the same rotation,
  // at roughly 1/SYSVIEW_FREERTOS_MAX_NOF_TASKS the scheduler-suspended time. Everything published
  // from a cached entry is a value copy (the name into names[] here, the rest plain integers), so a
  // task deleted mid-lap only makes its own entry stale, never a dangling dereference.
  static UBaseType_t n = 0;
  if (next_idx == 0) {
    n = uxTaskGetSystemState(status, TU_ARRAY_SIZE(status), NULL);
    for (UBaseType_t t = 0; t < n; t++) {
      strncpy(names[t], status[t].pcTaskName, configMAX_TASK_NAME_LEN - 1);
      names[t][configMAX_TASK_NAME_LEN - 1] = '\0';
    }
  }
  if (n == 0) { return; }
  UBaseType_t const i = next_idx;
  next_idx = (next_idx + 1 < n) ? next_idx + 1 : 0;

  SEGGER_SYSVIEW_TASKINFO info = {0};
  info.TaskID    = (U32)(uintptr_t) status[i].xHandle;
  info.sName     = names[i];
  info.Prio      = status[i].uxCurrentPriority;
  info.StackBase = (U32)(uintptr_t) status[i].pxStackBase;
  uint32_t const free_bytes = status[i].usStackHighWaterMark * sizeof(StackType_t);
  /* pxEndOfStack (highest valid stack address) is only in TaskStatus_t under
   * this same condition (task.h); when available, derive the true configured
   * stack depth so StackUsage is bytes used at peak rather than bytes free
   * (usStackHighWaterMark alone is headroom, the opposite of "usage"). */
  #if (portSTACK_GROWTH > 0) || (configRECORD_STACK_HIGH_ADDRESS == 1)
  uint32_t const stack_size = (uint32_t)(uintptr_t) status[i].pxEndOfStack -
                               (uint32_t)(uintptr_t) status[i].pxStackBase + sizeof(StackType_t);
  info.StackSize  = stack_size;
  info.StackUsage = (free_bytes < stack_size) ? (stack_size - free_bytes) : 0;
  #else
  // pxEndOfStack is unavailable (this port's stack does not grow upward, and
  // configRECORD_STACK_HIGH_ADDRESS is 0), so "bytes used at peak" cannot be computed here.
  // free_bytes is headroom -- the opposite of usage -- and SEGGER_SYSVIEW_TASKINFO has no
  // "unknown" sentinel for StackUsage, so there is no way to send it without it being read as
  // a (wrong) usage figure under the "stack high-water" label. Every family wired through
  // hw/bsp/sysview_freertos_hooks.h forces configRECORD_STACK_HIGH_ADDRESS=1 for exactly this
  // reason and never reaches this branch; a family that wires SYSVIEW without going through
  // that shared header can still land here, so refuse to publish rather than mislabel: skip
  // this task's Stack Info event for this cycle (next_idx above already advanced, so it is
  // retried on a later call, same as every other task in rotation).
  return;
  #endif
  SEGGER_SYSVIEW_SendTaskInfo(&info);
#endif
}

#endif /* CFG_TUD_SYSVIEW || CFG_TUH_SYSVIEW */