Integration without an RTOS: XCP on TCP from the main loop

The example examples/nucleo_h723zg_baremetal is the same board and the same demo content as examples/nucleo_h723zg, built without FreeRTOS and without sockets: lwIP runs in NO_SYS mode and the XCP server is the glue template glue/baremetal_lwip_raw/xcp_tcp_raw_glue.c, driven from the main loop. Everything in this chapter is what changes compared to Integration: XCP on TCP; the engine, the engine contract, the variable rules and the address tools are the same.

Same and different

Same as with FreeRTOSDifferent without an RTOS
the engine sources, qfw_xcp.h, cmake/qfw_xcp.cmake, QFW_XCP_TRANSPORT=eththe glue: glue/baremetal_lwip_raw/xcp_tcp_raw_glue.c instead of glue/freertos_lwip/xcp_sdk_glue.c — lwIP RAW (callback) API, no task, no socket
the clock port and the clock contractthe glue API: QfwXcpTcpRawGlue_Init(&config) once plus QfwXcpTcpRawGlue_Poll() on every pass of the main loop (the poll replaces the server task)
identity, log level and port in the config struct (tQfwXcpTcpRawGlueConfig has the same five fields as tQfwXcpGlueConfig)the context model: the transport on the main loop, the DAQ trigger from an interrupt — the glue gives the engine a PRIMASK critical section (see below)
qfw_xcp_create_event / qfw_xcp_trigger_event, daqId in QInsightwhere the calls live: main.c and stm32h7xx_it.c (USER CODE sections) instead of freertos.c; the DAQ event is triggered from the SysTick interrupt
the lwIP corrections of the STM32H7 project (linker script, ethernetif.c, see Troubleshooting)the lwIP options: NO_SYS 1, no socket options, but TCP_MSS / TCP_SND_BUF must be raised (see below)

CubeMX: lwIP without an RTOS

Where: your .ioc and LWIP/Target/lwipopts.h. What differs from the FreeRTOS project:

  1. Middleware → FREERTOS: not enabled. SYS → Timebase source: SysTick (HAL_GetTick drives the lwIP timers through sys_now and the example's demo scheduler).
  2. Connectivity → ETH: as in the FreeRTOS project. The ETH interrupt priority is free (no FreeRTOS syscall limit); in the CubeMX driver without an RTOS the ISR only clears DMA flags — frames are read by MX_LWIP_Process() from the main loop. Keep it that way (see the context model below).
  3. Middleware → LWIP: CubeMX generates NO_SYS 1, LWIP_NETCONN 0, LWIP_SOCKET 0 automatically when no RTOS is enabled. DHCP off, static IP as in the FreeRTOS project; MEM_SIZE 16384 and LWIP_RAM_HEAP_POINTER as in the example (the lwIP heap is where tcp_write copies the XCP segments).
  4. USER CODE BEGIN 1 of lwipopts.h — the options the glue depends on (the glue checks them at compile time: #error / _Static_assert):
    OptionValueWhy
    LWIP_TCP_KEEPALIVE1per-connection keepalive timing (2 s idle, 3 probes every 1 s): a master that vanished beyond the board's own link is detected within ~5 s and the port is free again (a cable pulled at the board: link callback, see Connections below)
    LWIP_NETIF_TX_SINGLE_PBUF1one pbuf per transmitted segment — the STM32H7 driver has only 4 TX descriptors
    TCP_MSS1460the Ethernet maximum (1500 − 40). With the lwIP default 536 every full engine segment would be cut into three TCP packets
    TCP_SND_BUF4 * TCP_MSS (5840)required. The glue queues a whole engine segment (up to 1480 B) with one tcp_write or not at all; the lwIP default (2 × 536 = 1072) could never accept a full segment and the engine would stay backpressured forever. Four segments in flight is a good default

    All four are #ifndef-guarded and not in the .ioc — the CubeMX GUI keeps showing the defaults; the USER CODE block survives regeneration. The values CubeMX emits for the default MSS (TCP_SND_QUEUELEN 9, TCP_SNDLOWAT 1071, …) still pass the lwIP sanity checks with these settings (TCP_SND_QUEUELEN ≥ 2 × TCP_SND_BUF / TCP_MSS = 8). Why whole segments: the engine drops queued segments on DISCONNECT, on a new CONNECT and on a transport reset; a half-written segment would desynchronize the byte stream. The FreeRTOS glue can afford partial writes because it may wait in its task; a main loop cannot.
  5. Generate code, then apply the STM32H7 corrections exactly as for the FreeRTOS project (linker script, ETH_RX_BUFFER_CNT, HAL_ETH_RxLinkCallback guard — REGEN-NOTES.md of the bare-metal example).

CMake

Where: your root CMakeLists.txt. How (the bare-metal example's CMakeLists.txt, only the SDK path differs):

include(${SDK}/cmake/qfw_xcp.cmake)               # transport eth (default)

add_library(qfw_example STATIC
    app/xcp_demo.c                                 # your application: event + cyclic work
    app/demo_map.c
    app/demo_signals.cpp
    ${QFW_XCP_GLUE_BAREMETAL_LWIP_RAW_SOURCES}   # glue/baremetal_lwip_raw/xcp_tcp_raw_glue.c
    ${QFW_XCP_PORT_CLOCK_SOURCES}                # port/clock/clock64.c
)
target_include_directories(qfw_example PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}/app
    ${QFW_XCP_GLUE_BAREMETAL_LWIP_RAW_INCLUDE_DIRS}
    ${QFW_XCP_PORT_CLOCK_INCLUDE_DIRS}
)
target_compile_definitions(qfw_example PUBLIC CLOCK_TICKS_PER_S=550000000UL)   # YOUR CPU clock
target_link_libraries(qfw_example PUBLIC qfw::xcp PRIVATE stm32cubemx)

target_link_libraries(${CMAKE_PROJECT_NAME} stm32cubemx qfw_example)

The glue needs the lwIP headers of your project (lwip/tcp.h, lwip/pbuf.h, your lwipopts.h), which is why it sits in your target and links stm32cubemx privately, exactly like the FreeRTOS glue.

main.c: init once, poll forever

Where: Core/Src/main.c, USER CODE sections only. What: a clock callback, the glue config, two init calls and the loop body. How (the example's main.c):

/* USER CODE BEGIN Includes */
#include "clock64.h"
#include "qfw_xcp.h"
#include "xcp_tcp_raw_glue.h"
#include "xcp_demo.h"
/* USER CODE END Includes */

/* USER CODE BEGIN 0 */
static uint64_t XcpClockUs(void)      /* engine clock contract: monotonic 64-bit microseconds */
{
  return Clock64_Get();
}
/* USER CODE END 0 */

int main(void)
{
  ...
  MX_GPIO_Init();
  MX_LWIP_Init();                     /* generated: lwIP is up after this line (NO_SYS: no thread is started) */
  /* USER CODE BEGIN 2 */
  Clock64_Init();
  /* USER CODE END 2 */
  ... BSP LEDs, COM1 (printf) ...

  /* USER CODE BEGIN WHILE */
  tQfwXcpTcpRawGlueConfig xcpConfig =
  {
    .project_name = "baremetal",      /* shown to the master, <= 31 chars */
    .epk = "V100",                    /* software version id */
    .port = 5555U,
    .log_level = 2U,                  /* 1 error, 2 warning, 3 info (adds connection messages) */
    .get_clock_us = XcpClockUs,
  };
  if (!QfwXcpTcpRawGlue_Init(&xcpConfig))   /* engine init + listening pcb */
  {
    Error_Handler();
  }
  if (!QfwXcpDemo_Init())                   /* qfw_xcp_create_event("demo10ms", 10000) -> id 0, maps */
  {
    Error_Handler();
  }

  while (1)
  {
    MX_LWIP_Process();         /* ETH RX + lwIP timers; the glue's TCP callbacks run in here -> qfw_xcp_rx */
    Clock64_Update();          /* single writer of the 64-bit clock, must run well below every 7.8 s */
    QfwXcpTcpRawGlue_Poll();   /* qfw_xcp_poll -> transmit callback -> tcp_write, then one tcp_output */
    QfwXcpDemo_Poll();         /* every 500 ms: map tick (the 10 ms signal step + DAQ event runs in SysTick, see below) */
    /* USER CODE END WHILE */
    /* USER CODE BEGIN 3 */
  }
  /* USER CODE END 3 */
}

Why this order: QfwXcpTcpRawGlue_Init must run after MX_LWIP_Init() (it creates the listening pcb) and, if you want to see its start-up message, after the COM port is initialized — that is why it is in USER CODE WHILE right before the loop and not in USER CODE 2. Inside the loop MX_LWIP_Process comes first (received commands are executed and their responses queued), then the poll transmits them in the same pass. QfwXcpDemo_Poll (app/xcp_demo.c) is the application's cyclic work: it schedules by HAL_GetTick with wrap-safe deadlines, so a late pass does not burst to catch up — copy its DemoDue pattern for your own loop cycles. The fast cycle (signal step + DAQ event every 10 ms) runs in QfwXcpDemo_Tick1ms from the SysTick interrupt (USER CODE SysTick_IRQn 1 in stm32h7xx_it.c), see the context model below. Two more USER CODE sections belong to the integration:

/* Core/Src/stm32h7xx_it.c */
/* USER CODE BEGIN Includes */
#include "xcp_demo.h"
/* USER CODE END Includes */
void SysTick_Handler(void)
{
  HAL_IncTick();
  /* USER CODE BEGIN SysTick_IRQn 1 */
  QfwXcpDemo_Tick1ms();      /* every 10 ms: generators + qfw_xcp_trigger_event, in interrupt context */
  /* USER CODE END SysTick_IRQn 1 */
}

/* LWIP/App/lwip.c - link state to the glue */
/* USER CODE BEGIN 0 */
#include "xcp_tcp_raw_glue.h"
/* USER CODE END 0 */
static void ethernet_link_status_updated(struct netif *netif)
{
  if (netif_is_up(netif)) {
/* USER CODE BEGIN 5 */
    QfwXcpTcpRawGlue_OnLinkChange(true);
/* USER CODE END 5 */
  } else {
/* USER CODE BEGIN 6 */
    QfwXcpTcpRawGlue_OnLinkChange(false);   /* master unreachable: drop the connection now */
/* USER CODE END 6 */
  }
}

Context model, critical section and interrupts

The transport runs in one context: the main loop. lwIP in NO_SYS mode runs only inside MX_LWIP_Process(), so the glue's recv callback (→ qfw_xcp_rx) and QfwXcpTcpRawGlue_Poll (→ qfw_xcp_poll) share it and the rule of the engine contract is met by construction.

Measurement comes from a second context. The glue registers a PRIMASK critical section (save PRIMASK, disable interrupts, restore on the outermost leave, with a nesting counter because the engine nests the section — see Critical section). With it, qfw_xcp_trigger_event may be called from an interrupt — the example triggers its demo10ms event from SysTick_Handler (QfwXcpDemo_Tick1ms, every 10 ms), the way a control loop in a timer ISR would: the DAQ samples and the STIM writes of that event happen inside the ISR, synchronously with the cycle, and the sampling period does not depend on how long a main-loop pass takes. The ISR only copies into the engine queue; the bytes leave the board in the next QfwXcpTcpRawGlue_Poll.

Consequences you must keep:

  • Only qfw_xcp_trigger_event from an interrupt. qfw_xcp_rx, qfw_xcp_poll and every other engine call stay in the main loop.
  • Nothing of lwIP from an interrupt. The CubeMX driver without an RTOS already behaves this way (the ETH ISR only clears flags); do not add ethernetif_input or lwIP calls to HAL_ETH_RxCpltCallback.
  • Keep the ISR work short. The section disables all interrupts for the duration of the queue bookkeeping and the DAQ copy of one event (microseconds); a trigger of a large event list inside a high-rate ISR adds that much interrupt latency to everything else.
  • Logging from the ISR is suppressed. An engine message raised inside the trigger is counted, not printed (printf blocks, and it would run with interrupts disabled); the count is reported from the next Poll as [XCP W] n engine log line(s) raised in interrupt context not printed. In a measurement run that line must not appear.
  • The glue is written for Cortex-M (__get_PRIMASK / __disable_irq / __set_PRIMASK from CMSIS). On another core replace the two functions by the equivalent global interrupt mask with the same nesting behaviour.
  • The loop pass is the poll cadence. Long work in one pass delays responses and DAQ transmission: printf at log_level 3 costs ≈ 4 ms per line on the ST-LINK VCP, and HAL_ETH_Transmit of the CubeMX driver without an RTOS blocks until the DMA has sent the frame (≈ 0.12 ms per full frame at 100 Mbit/s).

Connections: what the glue does and what you will see

The policy is the same as the FreeRTOS glue's, implemented on the RAW API:

SituationWhat happens
master connectsaccept callback: keepalive set (2 s / 1 s / 3), qfw_xcp_on_transport_reset(), the pcb becomes the one connection; [XCP I] master connected from ip:port at level 3
a second client connects while a master is connectedrefused at once with RST (tcp_abort in accept); the running session is not disturbed. [XCP W] refused a second connection from ip:port (master already connected). A second QInsight shows connected → forcibly closed once per second and connects as soon as the first session ends
master closes (FIN) or dies (RST)the glue aborts (RST) — every close is an abort, so the pcb and all queued segments are freed at once; engine reset; back to waiting for a master
master vanishes silently beyond the board's link (cable pulled at a switch, PC off or frozen)keepalive aborts the connection ~5 s after the last segment received; until then a reconnecting master is refused like a second client — QInsight's driver retries every second and gets in after the timeout
the byte stream desynchronizes (qfw_xcp_rx returns false)the connection is aborted (RST), [XCP E] XCP stream desynchronized
the lwIP send queue or heap is full (tcp_write → ERR_MEM)backpressure: the transmit callback returns false, the engine keeps the segment and offers the same bytes on the next poll; qfw_xcp_is_backpressured() is true meanwhile. A master that stays alive but stops reading keeps the glue in backpressure until it reconnects
link down at the board (cable pulled at the board)the link callback (ethernet_link_status_updated in lwip.c → QfwXcpTcpRawGlue_OnLinkChange(false)) makes the glue drop the master connection in the next Poll ([XCP I] connection closed (link down), waiting for a master at level 3). When the link is back the master's reconnect is accepted at once; with connectionTimeoutMs 1500 in QInsight the measurement is back about 3.5 s after the cable (link negotiation plus one timed-out attempt that started too early)

Nagle's algorithm is on (the TCP default, as in the FreeRTOS glue): a small packet may wait for the master's delayed ACK (≈ 200 ms on Windows) — DAQ timestamps are not affected, only the arrival of slow streams is batched. GLUE_TCP_NODELAY at the top of the glue switches to TCP_NODELAY.

The QInsight example project for this image (Qenex_TcpClient_XCP_NucleoH723_Maps_BareMetal.qproj) is described in Calibration maps over XCP on TCP.

Next

The engine contract — the rules behind the glue; Troubleshooting — the rows for the integration without an RTOS in the symptom table.