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 FreeRTOS | Different without an RTOS |
|---|---|
the engine sources, qfw_xcp.h, cmake/qfw_xcp.cmake, QFW_XCP_TRANSPORT=eth | the 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 contract | the 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 QInsight | where 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:
- Middleware → FREERTOS: not enabled. SYS → Timebase source:
SysTick (
HAL_GetTickdrives the lwIP timers throughsys_nowand the example's demo scheduler). - 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). - Middleware → LWIP: CubeMX generates
NO_SYS 1,LWIP_NETCONN 0,LWIP_SOCKET 0automatically when no RTOS is enabled. DHCP off, static IP as in the FreeRTOS project;MEM_SIZE16384 andLWIP_RAM_HEAP_POINTERas in the example (the lwIP heap is wheretcp_writecopies the XCP segments). USER CODE BEGIN 1oflwipopts.h— the options the glue depends on (the glue checks them at compile time:#error/_Static_assert):Option Value Why LWIP_TCP_KEEPALIVE1 per-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_PBUF1 one pbufper transmitted segment — the STM32H7 driver has only 4 TX descriptorsTCP_MSS1460 the 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_writeor 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; theUSER CODEblock 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 onDISCONNECT, on a newCONNECTand 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.- Generate code, then apply the STM32H7 corrections exactly as for
the FreeRTOS project (linker script,
ETH_RX_BUFFER_CNT,HAL_ETH_RxLinkCallbackguard —REGEN-NOTES.mdof 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_eventfrom an interrupt.qfw_xcp_rx,qfw_xcp_polland 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_inputor lwIP calls toHAL_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 (
printfblocks, and it would run with interrupts disabled); the count is reported from the nextPollas[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_PRIMASKfrom 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:
printfatlog_level3 costs ≈ 4 ms per line on the ST-LINK VCP, andHAL_ETH_Transmitof 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:
| Situation | What happens |
|---|---|
| master connects | accept 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 connected | refused 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.

