Publish live STM32F4 system-usage telemetry to ROS 2 over micro XRCE-DDS (the existing W5500 custom UDP transport), collected by a dedicated FreeRTOS task, implemented in dedicated files so the feature is:
- modular — one self-contained module (
firmware/sysusage.{c,h}) that owns its publisher, its message buffers and its task; - optional at build time — the module compiles to nothing when
NCAP_SYSUSAGE=0(the default), and builds with and without FreeRTOS.
- Toolchain:
arm-none-eabi-gcc15.2.1 present. Baseline builds clean:text 31912 data 120 bss 82176in ~1s withmake -C firmware -j8. - Single build file:
firmware/Makefile(138 lines). No CMake, no CubeIDE project. All conditionality is currently?=defaults plus-Dflags — keep that style, do not introduceifeq. - RAM: F446 has 128 KB. On the current working tree the default build is
data 388 bss 63248, sodata+bss= 63,636 B and ~67 KB headroom. (Thebss 82176figure below predatesov7670_dvp.candMX_SPI2_Init(); the real numbers are in "Outcome" at the end.) firmware/libmicroros.ais a prebuilt archive (built out-of-tree for-mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=soft). A new custom.msgcannot be added without rebuilding that 34.7 MB archive, so the module must use a stock type already linked in.diagnostic_msgs/msg/DiagnosticArraytype support is present inlibmicroros.a(verified vianm). It is the right choice: ROS 2's native representation for arbitrary key/value health data, so no IDL rebuild.- FreeRTOS V10.3.1 is vendored and configured but completely unbuilt: 34
tracked files under
firmware/Middlewares/Third_Party/FreeRTOS/Source/, none inSRC, no include path,configENABLE_FPU 0(contradicts the Makefile's-mfpu=fpv4-sp-d16),configTOTAL_HEAP_SIZE 15360,configTICK_RATE_HZ 1000,configMAX_PRIORITIES 56.Core/Src/freertos.c(830 lines, CMSIS-RTOS-v2, 5 robot-arm tasks) is CubeMX legacy unrelated to NCAP and is deliberately not built. Do not build it, do not delete it. Core/Src/stm32f4xx_it.c:251hasSysTick_Handlercalling onlyHAL_IncTick().SVC_HandlerandPendSV_Handlerdo not exist there at all (weakDefault_Handlerinstartup_stm32f446xx.s:270,276). FreeRTOS will fault-loop on start without them.Core/Src/main.cis a bare-metal super-loop with zero task creation and novTaskStartScheduler(). All failure paths arewhile (1) { }parks.- Single XRCE session: socket 0 / one client. Two tasks must never call
rclc_*concurrently — see the threading design below.
Two rclc_executors spinning the same XRCE client would interleave XRCE
datagrams on one UDP session and corrupt it. So:
- The existing
rclc_executor_tinmain.cstays the only rclc caller. UnderNCAP_FREERTOS=1it is spun by a dedicatedrcltask — see the correction in "Outcome" below, because handing the CPU to the scheduler without doing this publishes nothing at all. - The sysusage FreeRTOS task never touches rclc. It samples RTOS/kernel state into a plain-POD snapshot struct and publishes nothing.
- The existing 1 Hz
timer_callback()inmain.ccallssysusage_publish()on the executor's thread, which copies the latest snapshot into a preallocatedDiagnosticArrayand publishes it.
This is safe in both personalities: with NCAP_FREERTOS=0 there is no task and
sysusage_publish() samples inline from HAL_GetTick().
| Macro | Default | Effect |
|---|---|---|
NCAP_SYSUSAGE |
0 |
Compile the sysusage module at all. 0 ⇒ not in SRC, no symbols, no RAM. |
NCAP_FREERTOS |
0 |
Build the FreeRTOS kernel + scheduler. Prerequisite for NCAP_SYSUSAGE=1's task path. |
SYSUSAGE_PERIOD_MS |
1000 |
Publish period override. |
Matrix that must build (Agent D verifies):
NCAP_SYSUSAGE=0 NCAP_FREERTOS=0 (default: byte-identical to today)
NCAP_SYSUSAGE=1 NCAP_FREERTOS=0 (telemetry, inline sampling, no scheduler)
NCAP_SYSUSAGE=0 NCAP_FREERTOS=1 (scheduler, no telemetry)
NCAP_SYSUSAGE=1 NCAP_FREERTOS=1 (full feature: task + telemetry)
sysusage.c must compile silently with zero warnings in every one of these.
Target: < 8 KB of additional .bss in the full-feature build, keeping
data+bss under ~91 KB of 128 KB.
- Publish from preallocated static buffers only.
rosidl_runtime_c__Stringbufferfields must bestatic char[]arrays, and each*__Sequence.datamust point at astaticarray withcapacityset to its true length. Zeromalloc, zerostrdup, zerosnprintfinto heap.libmicroros.awill callrosidl_string_fini/finiand free nothing — never hand it a heap pointer. - Sequence sizes: 2
DiagnosticStatus(one"system", one"tasks"), ≤ 24KeyValueper status. - Do not enable
sensor_msgsimage or anything video-adjacent here. uxTaskGetSystemStateneeds astatic TaskStatus_t[]; size it 12 and clamp withuxTaskGetNumberOfTasks().
CPU load: configUSE_IDLE_HOOK 1 + a volatile idle-tick counter.
idle_pct = 100 * idle_delta / tick_delta, load_pct = 100 - idle_pct.
Snapshot is double-buffered; the handoff is a short
taskENTER_CRITICAL()/taskEXIT_CRITICAL() around a pointer/index flip only —
never around rclc, never around SPI/W5500.
Task-to-snapshot handoff must be a plain struct copy guarded by a critical
section, not a FreeRTOS queue, to keep RAM flat. Task is statically
allocated (xTaskCreateStatic) so it does not consume configTOTAL_HEAP_SIZE
— the heap should be shrunk, not grown, because the F446 has no RAM to spare
and micro-ROS already uses a separate static pool.
| Agent | Owns — may edit | Must NOT touch |
|---|---|---|
| A | firmware/sysusage.h (new), firmware/sysusage.c (new) |
anything else |
| B | firmware/Makefile, firmware/Core/Inc/FreeRTOSConfig.h |
anything else |
| C | firmware/Core/Src/stm32f4xx_it.c, firmware/Core/Src/main.c |
anything else |
| D | README.md, scripts/verify_sysusage.sh (new) |
firmware sources |
firmware/w5500_transport.c was unowned by this table and turned out to be
load-bearing; the reconciler added the ncap_cpu_relax() hook to it. See
"Outcome: what the plan got wrong" at the end.
#ifndef NCAP_SYSUSAGE_H
#define NCAP_SYSUSAGE_H
#include <stdbool.h>
#include <stdint.h>
#ifndef NCAP_SYSUSAGE
#define NCAP_SYSUSAGE 0
#endif
#if NCAP_SYSUSAGE == 1
#include <rcl/allocator.h>
#include <rcl/publisher.h>
#include <rclc/node.h>
#include <rclc/support.h>
#include <diagnostic_msgs/msg/diagnostic_array.h>
#ifndef SYSUSAGE_PERIOD_MS
#define SYSUSAGE_PERIOD_MS 1000u
#endif
/* Create the /diagnostics publisher. Call once, after rclc_support_init and
rclc_node_init_default, before rclc_executor_init is fine either way.
topic_name NULL => "diagnostics". Returns false on failure; caller should
keep running heartbeat-only rather than park. */
bool sysusage_publisher_init(rclc_support_t *support,
rcl_node_t *node,
rcl_allocator_t *allocator,
const char *topic_name);
/* Facts main.c knows and sysusage.c cannot discover. Pass NULL to clear. */
typedef struct {
uint32_t uptime_ms; /* HAL_GetTick() snapshot at publish time */
uint32_t pool_size; /* static RMW pool bytes (main.c POOL_SIZE) */
uint32_t pool_used; /* main.c pool_used */
uint32_t video_fps_x100; /* achieved emulated video fps x100, 0 = n/a */
} sysusage_extra_t;
void sysusage_set_extra(const sysusage_extra_t *extra);
/* Start the sampler task. Must be called BEFORE vTaskStartScheduler().
Returns true if a task is running. Returns false (not an error) when
NCAP_FREERTOS == 0: sysusage_publish() then samples inline instead. */
bool sysusage_task_start(void);
/* Fill + publish one DiagnosticArray. Call ONLY from the rclc executor's
thread (the existing timer callback). Never from a task. */
rcl_ret_t sysusage_publish(void);
/* Last published message, for tests/debug. NULL before first publish. */
const diagnostic_msgs__msg__DiagnosticArray *sysusage_last(void);
#endif /* NCAP_SYSUSAGE == 1 */
#endif /* NCAP_SYSUSAGE_H */-
#include "sysusage.h"and the explicitrosidl_typesupport_microxrcedds_c__get_message_type_support_handle__diagnostic_msgs__msg__DiagnosticArray()forward declaration, matching howsensor_msgs/msg/Imuis already declared atmain.c:30-33. -
After
rclc_node_init_default(main.c:294) and before executor spin:#if NCAP_SYSUSAGE == 1→sysusage_publisher_init(&support, &node, &allocator, NULL); on failureprintf("F4: sysusage publisher unavailable \r\n")and continue (never park). -
In
timer_callback(main.c:148), after the IMU publish atmain.c:169, inside#if NCAP_SYSUSAGE == 1: populate asysusage_extra_tfrompool_used/POOL_SIZE/HAL_GetTick()and callsysusage_set_extra()thensysusage_publish(). Ignore its return value except for a first-failure message, so a telemetry hiccup never stalls the heartbeat. -
Immediately before the final
for (;;)spin loop (main.c:363/370):#if NCAP_FREERTOS == 1→sysusage_task_start()ifNCAP_SYSUSAGE == 1, thenvTaskStartScheduler(). FreeRTOS must own the loop — call it there and let it never return; do not keep a bare-metal spin as a fallback inside the same#if, becausemain.c's existing spin loop must remain the path forNCAP_FREERTOS=0(this also preserves the existingrclc_executor_spin_some+video_udp_step()cooperative mode untouched).Consequence Agent C must resolve: with
NCAP_FREERTOS=1,video_udp_step()cooperation is lost unless it becomes a task. Keep it simple and in scope:NCAP_FREERTOS=1andNCAP_VIDEO_UDP=2are mutually exclusive; have Agent B emit#errorif both are set, so the limitation is enforced by the compiler instead of silently misbehaving. -
stm32f4xx_it.c— inside the existing/* USER CODE BEGIN NCAP */block,#if NCAP_FREERTOS == 1:void SVC_Handler(void) { vPortSVCHandler(); } void PendSV_Handler(void) { xPortPendSVHandler(); }
and change
SysTick_Handlerto call bothxPortSysTickHandler()andHAL_IncTick()—w5500_transport.c:180-197andvideo_udp.cboth useHAL_GetTick()deadlines, so HAL's tick must keep advancing. Needs#include "FreeRTOS.h"+#include "task.h"inside that guard.
configENABLE_FPU 0→1. The Makefile passes-mfpu=fpv4-sp-d16andlibmicroros.awas built for it; leaving this 0 makes the CM4F port skip FPU context save while the compiler still emits FP instructions.configUSE_IDLE_HOOK 0→1(CPU load measurement).configTOTAL_HEAP_SIZE 15360→4096. Sysusage uses a static task; 15 KB of BSS for a heap nothing allocates from is 15 KB of the 48 KB we do not have.configUSE_TIMERS 1→ keep (timer task already accounted for). ConfirmconfigSUPPORT_STATIC_ALLOCATION 1andINCLUDE_uxTaskGetStackHighWaterMark 1stay on (both already are) — sysusage depends on the latter.- Add the kernel sources to
SRConly whenNCAP_FREERTOS=1. Since the Makefile style is-D-driven rather thanifeq-driven, express this with a$(if $(filter 1,$(NCAP_FREERTOS)),<sources>)expansion — that is the one sanctioned exception to the no-ifeqstyle, because the source list genuinely cannot be-D-gated. Same trick forsysusage.c. - New include paths:
-IMiddlewares/Third_Party/FreeRTOS/Source/include,-IMiddlewares/Third_Party/FreeRTOS/Source/CMSIS_RTOS_V2. - New
-Dflags:-DNCAP_SYSUSAGE=$(NCAP_SYSUSAGE),-DNCAP_FREERTOS=$(NCAP_FREERTOS),-DSYSUSAGE_PERIOD_MS=$(SYSUSAGE_PERIOD_MS). - Keep
-mfloat-abi=softuntouched (matches the prebuilt archive).
Recorded after implementation, so the next reader does not repeat these. The code is correct; these are the assumptions that did not survive contact with it.
- The baseline numbers in "Verified baseline" are stale.
text 31912 data 120 bss 82176predatesov7670_dvp.candMX_SPI2_Init(). The real working -tree baseline istext 92468 data 388 bss 63160(verified fromgit archive HEAD). The "~48 KB headroom" figure is really ~67 KB. Everything measured against the plan's numbers was off by ~19 KB. - The plan never assigned the executor to anyone. It said "the existing
rclc_executor_tstays the only rclc caller" and then handed the CPU tovTaskStartScheduler(), which of course means nobody calls the executor. The first implementation shipped a firmware that linked, started a scheduler and published nothing at all — with a source comment asserting that was "by design". UnderNCAP_FREERTOS=1the executor must run inside a task. It isrclinmain.c, at priority 2. configENABLE_FPUis not a FreeRTOS V10.3.1 option. The plan's rationale ("the CM4F port was skipping FPU context save while the compiler still emitted FP instructions") assumed the macro is read by the kernel. Nothing reads it. The actual blocker is thatport.cassemblesvstmdbeq/vldmiaeq, which cannot be assembled under-mfloat-abi=softat any-mfpu. Onlyport.cis now built with-mfloat-abi=softfp; the macro stays 0 with a comment explaining why it is inert.vApplicationGetIdleTaskMemory/vApplicationGetTimerTaskMemory/vApplicationIdleHookare application-owned, and the plan assigned none of them. They are plainextern, not__weak, andcmsis_os2.c(which does carry weak fallbacks) is excluded fromSRC. TheNCAP_SYSUSAGE=0 NCAP_FREERTOS=1row has nosysusage.cto own them, so they must live inmain.cand be weak to avoid colliding withsysusage.c's idle hook.configUSE_TIMERS 0requiresINCLUDE_xTimerPendFunctionCall 0.timers.c:42hard-errors otherwise. Cheaper than the ~1356 B of.bssthe timer daemon costs, and nothing in the tree uses it.- Two more config trims were needed to fit the RAM budget: the feature's own
rcltask pushed.bssto +8736 B against an 8192 B cap.configMAX_PRIORITIES 56 → 8(this firmware has 3 priorities) andconfigUSE_TIMERS 1 → 0together gave back 2440 B, landing at +6296 B. - The header staleness footgun is worse than "Makefile variables". The object
rules depend on the source and on
Makefileonly, so editingFreeRTOSConfig.hrecompiles nothing.scripts/verify_sysusage.shwas silently verifying the previous run's binary because it reused its scratch dirs; it nowrm -rfs each one first. xTaskCreateStatic's V10.3.1 argument order is(fn, name, depth, params, prio, stack, tcb)— not the CMSIS-v2(fn, name, stack, tcb, attrs)order used byCore/Src/freertos.c. It returns aTaskHandle_t, so it is checked againstNULL, notpdPASS.- The plan's
SVC_Handler/PendSV_Handlerrecipe is a duplicate-symbol error.FreeRTOSConfig.h:172,173already#define vPortSVCHandler SVC_HandlerandxPortPendSVHandler PendSV_Handler, soport.calready is the kernel. Defining them again instm32f4xx_it.cboth duplicates the symbol and, because the macro rewrites the call into itself, compiles as infinite recursion. The definitions are guarded by#if !defined(vPortSVCHandler). - The plan's
<rclc/support.h>include does not exist;rclc_support_tlives inrclc/types.h.
make -C firmwarewith both defaults: 63,636 B ofdata+bss, and the feature costs +248 B text / +88 B bss, which is the weakncap_cpu_relax()stub and its call site.- All four matrix rows build with zero new warnings under
-Wall. - Full feature adds +6,296 B of
.bss, leaving 61,216 B of SRAM free. diagnostic_msgs__msg__DiagnosticArrayis in the full-feature ELF and nosysusage_*symbol is in the default ELF.- README documents both macros, all four modes, and the gotchas.
Verified by ./scripts/verify_sysusage.sh (all rows PASS). Not verified in
Renode: no /diagnostics message has been observed on the wire yet, so the
runtime behaviour remains designed-and-compiled rather than proven.
- Rebuilt
.msg/IDL (blocked by the prebuiltlibmicroros.a). Core/Src/freertos.cand the CubeMX robot-arm tree — leave untouched.- Enabling lwIP, or a second XRCE client.
- Renode-side changes or a new peripheral model.