Integration step by step: XCP on TCP with FreeRTOS + lwIP
This chapter takes you from a new STM32CubeMX project to a board that
QInsight can connect to, measure (DAQ), calibrate and stimulate (STIM)
over XCP on TCP. Every step states where (file), what, how
(code or setting) and why. All code is taken from the shipped example
(examples/nucleo_h723zg in the SDK package), which is the reference for
every name used here. What changes for classic CAN is in
XCP on classic CAN; what changes without FreeRTOS in
Integration without an RTOS.
The shortest path is to build the example first
(examples/nucleo_h723zg/README.md): configure the Debug preset, build,
flash, connect QInsight. When that works you know the toolchain, the
network and QInsight are fine, and every step below has a working
counterpart to compare against.
CubeMX: the project the SDK expects
Where: STM32CubeMX, your .ioc. Why: the glue needs a running
FreeRTOS scheduler, a running lwIP stack with the socket API and a task to
be started from. The example .ioc (examples/nucleo_h723zg/nucleo_h723zg.ioc)
is the reference; the settings that matter for XCP are listed here.
- MCU / board: NUCLEO-H723ZG (STM32H723ZGT6) in the example; any
STM32H7 with Ethernet works the same way. Toolchain / IDE: CMake
(Project Manager → Toolchain), so that
cmake/qfw_xcp.cmakecan be included. - Clock tree: note the CPU frequency — you will need it as
CLOCK_TICKS_PER_Sin the CMake step. The example runs at 550 MHz (HSE 8 MHz bypass → PLL1). On the H723, 550 MHz additionally requires theCPU_FREQ_BOOSToption byte (see Troubleshooting). - SYS → Timebase source: a hardware timer (TIM6 in the example), so that SysTick belongs to FreeRTOS.
- Middleware → FREERTOS: interface CMSIS_V2. Under Tasks and
Queues create two tasks:
Task Priority Stack (words) Entry Purpose defaultTaskosPriorityNormal (24) 1024 StartDefaultTaskCubeMX starts lwIP here ( MX_LWIP_Init); you add the XCP init and the clock tickdemoTaskosPriorityBelowNormal (16) 512 StartDemoTaskyour cyclic application task that produces the data and triggers the DAQ event
The XCP server task itself is not created in CubeMX — the glue creates it statically (xTaskCreateStatic, nameQfwXcpGlue, 4 KB stack, prioritytskIDLE_PRIORITY + 2; constants at the top ofxcp_sdk_glue.c). Under Config parameters keepUSE_RECURSIVE_MUTEXESandSUPPORT_STATIC_ALLOCATIONenabled (the glue uses a recursive mutex and allocates nothing at runtime).TOTAL_HEAP_SIZEis 65536 in the example (lwIP and CMSIS-RTOS allocate from it). - Connectivity → ETH: RMII (LAN8742 PHY on the Nucleo). NVIC: the
ETH interrupt priority must not be above the FreeRTOS
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY(5 in the example) — the lwIP port calls FreeRTOS from that ISR. - Middleware → LWIP: enable it. General settings: DHCP off, static
IP (example:
192.168.2.20, mask255.255.255.0, gateway192.168.2.1); QInsight connects to this address. Key options that XCP depends on:Option Value Why LWIP_SO_RCVTIMEO1 the server task receives with a 1 ms timeout; that timeout is the poll cadence of the transmit pump. Without it lwip_recvblocks forever and nothing is ever transmitted.LWIP_TCP_KEEPALIVE1 per-socket keepalive (2 s idle, 3 probes every 1 s in the glue, ≈ 5 s) detects a vanished master (cable pulled beyond the board's own link, PC switched off) — otherwise the board stays "connected" forever and refuses the next master. A cable pulled at the board is handled faster by the link callback (see the lwip.ccall below)LWIP_SO_LINGER1 the glue closes the data socket with SO_LINGER{on,0}= TCP RST: the pcb and every queued segment are freed at once instead of lingering in retransmission to a master that is goneLWIP_NETCONN_SEM_PER_THREAD1 one lwIP semaphore per calling thread (requires configNUM_THREAD_LOCAL_STORAGE_POINTERS ≥ 1in FreeRTOS, see theUSER CODEblock of the example'slwipopts.h)LWIP_NETCONN_FULLDUPLEX1 shutdown from another context while recvis pending — used by the transmit stall recovery of the glueLWIP_SO_SNDTIMEO1 send timeout, so that a master that stopped reading cannot block the stack MEM_SIZE16384 lwIP heap; the XCP data socket alone needs several TCP segments in flight under DAQ load TCPIP_THREAD_STACKSIZE4096 the lwIP thread also runs the socket API paths LWIP_SO_RCVTIMEO,LWIP_NETCONN_SEM_PER_THREAD,LWIP_NETCONN_FULLDUPLEX,MEM_SIZEandTCPIP_THREAD_STACKSIZEare CubeMX parameters (they are carried by the.ioc).LWIP_TCP_KEEPALIVE,LWIP_SO_LINGERandLWIP_SO_SNDTIMEOare not exposed by CubeMX — put them into theUSER CODE BEGIN 1block ofLWIP/Target/lwipopts.h, guarded with#ifndef, exactly as the example does; that block survives regeneration. - Generate code. On STM32H7 the generated project is not yet usable
for Ethernet: the Ethernet DMA cannot reach DTCM RAM, the RX buffer
count and the interface thread stack of
ethernetif.care wrong. The example's linker script andREGEN-NOTES.mddocument the corrections; apply them before the first build (see Troubleshooting).
Add the SDK to the CMake project
Where: the CMakeLists.txt that CubeMX generated at the root of your
project (it is generated once and never overwritten). What: build the
engine as qfw::xcp, compile the glue and the clock port as your own
sources, link both to the firmware. How (this is the example's
CMakeLists.txt, only the SDK path differs):
# after add_subdirectory(cmake/stm32cubemx)
include(${SDK}/cmake/qfw_xcp.cmake) # SDK = path to the SDK package
add_library(qfw_example STATIC
app/demo_signals.cpp
app/xcp_server.c # your integration file
${QFW_XCP_PORT_CLOCK_SOURCES} # port/clock/clock64.c
)
target_include_directories(qfw_example PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/app
${QFW_XCP_PORT_CLOCK_INCLUDE_DIRS}
)
if(QFW_XCP_TRANSPORT STREQUAL "can") # see XCP on classic CAN
target_sources(qfw_example PRIVATE ${QFW_XCP_GLUE_FREERTOS_CAN_SOURCES} ${QFW_XCP_PORT_FDCAN_SOURCES})
target_include_directories(qfw_example PUBLIC ${QFW_XCP_GLUE_FREERTOS_CAN_INCLUDE_DIRS} ${QFW_XCP_PORT_FDCAN_INCLUDE_DIRS})
target_compile_definitions(qfw_example PUBLIC QFW_XCP_TRANSPORT_CAN ${QFW_XCP_PORT_FDCAN_DEFINES})
else()
target_sources(qfw_example PRIVATE ${QFW_XCP_GLUE_FREERTOS_LWIP_SOURCES}) # glue/freertos_lwip/xcp_sdk_glue.c
target_include_directories(qfw_example PUBLIC ${QFW_XCP_GLUE_FREERTOS_LWIP_INCLUDE_DIRS})
endif()
target_compile_definitions(qfw_example PUBLIC CLOCK_TICKS_PER_S=550000000UL) # YOUR CPU clock
target_compile_options(qfw_example PRIVATE $<$<COMPILE_LANGUAGE:CXX>:-fno-exceptions -fno-rtti>)
target_link_libraries(qfw_example PUBLIC qfw::xcp PRIVATE stm32cubemx) # stm32cubemx = HAL/FreeRTOS/lwIP of your project
target_link_libraries(${CMAKE_PROJECT_NAME} stm32cubemx qfw_example)
Why this shape: qfw::xcp is a static library target that
qfw_xcp.cmake builds from engine/src with include/ on its interface —
the engine is compiled with your toolchain file and your optimization
level, nothing else is needed. The glue and the port are your sources:
they need the HAL/FreeRTOS/lwIP headers of your project, which is why they
sit in your target and link stm32cubemx privately. QFW_XCP_TRANSPORT
(eth, the default, or can) is a cache variable read by
qfw_xcp.cmake; the example sets it per preset (CMakePresets.json:
Debug → eth, DebugCan → can).
Without CMake: compile the engine/src/*.c files listed in
cmake/qfw_xcp.cmake — qfw_xcptl.c for Ethernet OR qfw_xcptl_can.c
for CAN, never both — with -D_QFW_ENGINE -DNDEBUG -DXCPLIB_CFG_OVERRIDE="xcplib_rtos_cfg.h" (xcplib_can_cfg.h and
-D_QFW_XCP_CAN for CAN) and the include paths include/, engine/src/.
The glue and port files are ordinary C sources of your project.
Clock port
Where: port/clock/clock64.c + clock64.h (already in your target
from the CMake step). What: a 64-bit microsecond clock for DAQ
timestamps. How: the file widens the 32-bit Cortex-M7 DWT cycle counter
with an overflow counter and converts ticks to microseconds with integer
arithmetic. It needs:
CLOCK_TICKS_PER_S= your CPU clock in Hz, an integer multiple of 1 MHz, set as a compile definition in the CMake step. There is no default: without it the build stops with#errorinclock64.h, because a wrong value would not fail but silently produce timestamps at the wrong rate;Clock64_Init()once, afterSystemClock_Config()(it enables the DWT);Clock64_Update()from one single task, periodically — it is the only writer of the overflow counter and must run at least once per DWT wrap (2^32 / 550 MHz = 7.8 s; the example calls it every 500 ms);Clock64_Get()from anywhere, lock-free.
Why: the engine's DAQ timestamps are 64-bit microseconds and must
never wrap or go backwards. If your MCU has no DWT or you already have a
64-bit microsecond timebase, replace the file — the engine only sees the
get_clock_us function you pass to the glue.
Your integration file
Where: a new file in your application, e.g. app/xcp_server.c + a
small header xcp_server.h with the three functions below. What:
clock → identity → glue configuration → QfwXcpGlue_Init →
qfw_xcp_create_event. How (this is
examples/nucleo_h723zg/app/xcp_server.c reduced to the TCP path; adapt
the three constants):
#include "xcp_server.h"
#include <stddef.h>
#include "clock64.h"
#include "qfw_xcp.h"
#include "xcp_sdk_glue.h"
#define QFW_XCP_PROJECT_NAME "qfirmware" /* shown to the master, <= 31 chars */
#define QFW_XCP_EPK "V100" /* software version id, <= 31 chars */
#define QFW_XCP_PORT 5555U /* TCP port QInsight connects to */
/* Cycle time of your data-producing task, advertised to the master as the event rate. */
#define QFW_XCP_DEMO_EVENT_CYCLE_US (10U * 1000U)
static uint16_t sDemoEventId = QFW_XCP_INVALID_EVENT;
/* Engine clock contract: monotonic 64-bit MICROSECONDS. clock64 already
converts ticks to microseconds - do not divide again. */
static uint64_t QfwXcpClockUs(void)
{
return Clock64_Get();
}
bool QfwXcpServer_Init(void)
{
Clock64_Init();
tQfwXcpGlueConfig config = {
.project_name = QFW_XCP_PROJECT_NAME,
.epk = QFW_XCP_EPK,
.port = QFW_XCP_PORT,
.log_level = 2U, /* 0 silent, 1 error, 2 warning, 3 info, 4 trace, 5-6 debug */
.get_clock_us = QfwXcpClockUs,
};
if (!QfwXcpGlue_Init(&config))
{
return false;
}
/* First event -> id 0 = daqId="0" in the QInsight project. */
sDemoEventId = qfw_xcp_create_event("demo10ms", QFW_XCP_DEMO_EVENT_CYCLE_US);
return sDemoEventId != QFW_XCP_INVALID_EVENT;
}
void QfwXcpServer_Tick(void)
{
/* Single writer of the clock64 overflow counter. */
Clock64_Update();
}
void QfwXcpServer_TriggerDemoEvent(void)
{
/* No-op until the event exists: the data task may start before the XCP bring-up. */
if (sDemoEventId != QFW_XCP_INVALID_EVENT)
{
qfw_xcp_trigger_event(sDemoEventId);
}
}
Why each part:
Clock64_Init()first — the glue's clock callback is used from the momentqfw_xcp_initruns.project_nameandepkare the identity the master reads withGET_ID(see The version string).portis the TCP server port.log_levelgates the engine and glue log lines that reachprintf.QfwXcpGlue_Init(inglue/freertos_lwip/xcp_sdk_glue.c) creates the recursive mutex, fillsqfw_xcp_config_t(transmit, clock, critical section, log, queue memory), callsqfw_xcp_initand creates the server task. It returnsfalsewhenget_clock_usis missing, the mutex cannot be created,qfw_xcp_initfails or the task cannot be created.- Events: create them after
QfwXcpGlue_Initand before the master connects — in practice all of them at init. The first event gets id 0, the next 1, …; QInsight references the id asdaqIdin its events. A name is at most 15 characters; at most 16 events exist (docs/configuration.md).cycle_time_usis informative for the master (0 = sporadic). - Trigger an event right after the variables it covers were updated,
from the task that updated them. Inside
qfw_xcp_trigger_eventthe engine first writes pending STIM data into memory, then samples the DAQ lists bound to the event into the transmit queue with a timestamp. While no measurement is running the call is a cheap no-op, so call it unconditionally.
Call it from the cyclic task, the same way the example does in
app/demo_signals.cpp:
for (;;)
{
gDemoSignals.Update(Clock64_Get()); /* produce this cycle's values */
QfwXcpServer_TriggerDemoEvent(); /* DAQ samples them, STIM writes into them */
osDelay(10);
}
The order matters for STIM: the value written by the master lands after
your update, so your code must not overwrite it later in the same cycle
(see the raw/value split with the bypass flag in demo_signals.cpp).
Important: What happens to a stimulated value when the master goes away is your decision, not the engine's. After a DISCONNECT or a transport reset the engine only reports
qfw_xcp_is_connected() == falseand stops applying STIM data; the last written value stays in memory. Decide in your task whether to fall back to the internal model, hold the value or switch to a safe state (the example keeps the last value). On classic CAN there is no connection, so this is signalled only after a regular DISCONNECT, not after a disconnected cable.
Three calls in freertos.c
Where: Core/Src/freertos.c, generated by CubeMX. What: the only
board-side edits: an include, the XCP init plus the clock tick in the
default task, and your application task body. How — all inside
USER CODE sections, so they survive regeneration:
/* USER CODE BEGIN Includes */
#include "demo_signals.h"
#include "xcp_server.h"
/* USER CODE END Includes */
void StartDefaultTask(void *argument)
{
/* init code for LWIP */
MX_LWIP_Init(); /* generated by CubeMX - lwIP is up after this line */
/* USER CODE BEGIN StartDefaultTask */
if (!QfwXcpServer_Init()) /* engine + glue + server task; false = look at the log */
{
BSP_LED_On(LED_RED);
}
for(;;)
{
QfwXcpServer_Tick(); /* single writer of the 64-bit clock, well below 7.8 s */
BSP_LED_Toggle(LED_GREEN); /* heartbeat */
osDelay(500);
}
/* USER CODE END StartDefaultTask */
}
void StartDemoTask(void *argument)
{
/* USER CODE BEGIN StartDemoTask */
QfwDemoSignals_Task(); /* your cyclic task body; never returns */
/* USER CODE END StartDemoTask */
}
Why here: QfwXcpServer_Init needs a task context (it creates a mutex
and a task) and a running lwIP (MX_LWIP_Init is called by the generated
code at the top of the default task, not in main()). main() itself is
initialization only — osKernelStart() never returns, so nothing after it
runs.
One more call, in LWIP/App/lwip.c — the link callback CubeMX
generates (ethernet_link_status_updated, called by the EthLink thread
when the PHY reports a link change) tells the glue about the link:
/* USER CODE BEGIN 0 */
#include "xcp_sdk_glue.h"
/* USER CODE END 0 */
static void ethernet_link_status_updated(struct netif *netif)
{
if (netif_is_up(netif))
{
/* USER CODE BEGIN 5 */
QfwXcpGlue_OnLinkChange(true);
/* USER CODE END 5 */
}
else
{
/* USER CODE BEGIN 6 */
QfwXcpGlue_OnLinkChange(false); /* master unreachable: drop the connection now */
/* USER CODE END 6 */
}
}
On link down the glue drops the master connection at once. Without this
call the board would keep the stale connection until the keepalive
expires (≈ 5 s) and — because a connected master is never replaced —
refuse the master's reconnect meanwhile. The example guards the include
and both calls with #ifndef QFW_XCP_TRANSPORT_CAN, because the same
CubeMX project is also built as the CAN image (DebugCan), which has no
TCP glue.
Important: Rules for
USER CODEsections: edit generated files only betweenUSER CODE BEGIN/END; the section names are fixed by the ST template (Includes,PV,PFP,0–6,Init,WHILE,StartDefaultTask, …) — content in an invented section name is dropped silently by the next regeneration. Task names, priorities and stacks belong in the.ioc, not infreertos.c. Details in Troubleshooting.
Variables that QInsight reads and writes
Where: your application data. What: every variable that QInsight measures, calibrates or stimulates must be reachable by an absolute address that is stable for the lifetime of the firmware image. How:
- make it a global or static object (never a local/stack variable);
- declare it
volatile— the engine reads and writes it through a pointer from another task; withoutvolatile,-Osmay keep the value in a register or remove the object altogether; - use a fixed layout: standard-layout structs, no pointers to follow,
static_asserton the member offsets you publish (the example'sSineChannelpinsamplitude +0, period_s +4, raw +8, value +12, bypass +16andsizeof == 24); - give it a readable linker name (the example declares the C++ object with
extern "C"so that it appears asgDemoSignalsin the.map).
Why: QInsight addresses ECU memory directly — address="0x…",
addressExtension="0" in the variable's Comm params; the engine treats
the address as a pointer. No A2L file is involved. The addresses come from
the ELF of the build you flash;
Variable addresses for QInsight shows
how to extract and import them.
Build, flash, verify
Where: a shell with STM32CubeCLT on the PATH (arm-none-eabi GCC, CMake, Ninja, STM32CubeProgrammer). How:
cmake --preset Debug && cmake --build --preset Debug
STM32_Programmer_CLI -c port=SWD -w build/Debug/<project>.elf -rst
(Debug compiles with -O0 -g3; Release with -Os -g0, which is where
the volatile rule above starts to matter. Both generate a .map next to
the ELF.)
What you should see on the board:
- Green LED blinking every 500 ms — the scheduler and the default
task are running. Red LED on —
QfwXcpServer_Initreturnedfalse(see Troubleshooting). ping 192.168.2.20from the PC answers — lwIP, the PHY and the static IP are right (PC in the same subnet).- On the ST-LINK virtual COM port (115200 8N1; the ST BSP retargets
printfto it in the example) the glue prints[XCP I] SDK server listening on TCP port 5555at start and[XCP I] master connectedwhen QInsight connects. Withlog_level >= 3the engine also prints its start-up configuration. - Port 5555 is reachable:
Test-NetConnection 192.168.2.20 -Port 5555(PowerShell) or a telnet client shows the connection opening.
Connect QInsight
Where: QInsight, Home → Project Configuration. How:
- Driver: TCP Client with
ip= the board's address,port= 5555. Recommended:keepAliveMs5000, so that the PC side also detects a lost board, andconnectionTimeoutMs1500: after a cable pull the driver's first reconnect attempt usually starts while the link is still negotiating, and a long connect timeout only delays the next attempt (the example projects use 1500). - Protocol: XCP on TCP under that
driver. Keep
daqTimestamps=slaveto use the board's 64-bit microsecond clock as the time axis. - Events: a polling event (e.g. 500 ms) for calibration values, and a
DAQ event with Extra Params
direction="DAQ";daqId="0"—0is the id returned by the firstqfw_xcp_create_eventin your integration file. A STIM event usesdirection="STIM";daqId="0"on the same channel (see Events). - Variables: import them from the file produced by the address tools,
assign each to the XCP protocol and fill the Comm params (
address,addressExtension="0",direction,eventRef) — see Variable addresses for QInsight. - Start the runtime. The board log shows
master connected; DAQ values move in the workspace.
The QInsight example projects for this firmware
(Qenex_TcpClient_XCP_NucleoH723_Maps_FreeRTOS.qproj,
Qenex_CAN_XCP_NucleoH723_Maps_FreeRTOS.qproj and
Qenex_TcpClient_XCP_2NucleoBoards_DAQ_STIM.qproj) are installed in the
Examples folder next to QInsight.exe; they are documented in
XCP on TCP on a NUCLEO-H723ZG,
XCP on CAN on a NUCLEO-H723ZG and
Two ECUs at once. The bare-metal image
(examples/nucleo_h723zg_baremetal) has its own project
Qenex_TcpClient_XCP_NucleoH723_Maps_BareMetal.qproj, see
Integration without an RTOS.
Next
XCP on classic CAN — what changes for the CAN transport; Integration without an RTOS — what changes without FreeRTOS; The engine contract — read before going live.

