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.

  1. 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.cmake can be included.
  2. Clock tree: note the CPU frequency — you will need it as CLOCK_TICKS_PER_S in the CMake step. The example runs at 550 MHz (HSE 8 MHz bypass → PLL1). On the H723, 550 MHz additionally requires the CPU_FREQ_BOOST option byte (see Troubleshooting).
  3. SYS → Timebase source: a hardware timer (TIM6 in the example), so that SysTick belongs to FreeRTOS.
  4. Middleware → FREERTOS: interface CMSIS_V2. Under Tasks and Queues create two tasks:
    TaskPriorityStack (words)EntryPurpose
    defaultTaskosPriorityNormal (24)1024StartDefaultTaskCubeMX starts lwIP here (MX_LWIP_Init); you add the XCP init and the clock tick
    demoTaskosPriorityBelowNormal (16)512StartDemoTaskyour 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, name QfwXcpGlue, 4 KB stack, priority tskIDLE_PRIORITY + 2; constants at the top of xcp_sdk_glue.c). Under Config parameters keep USE_RECURSIVE_MUTEXES and SUPPORT_STATIC_ALLOCATION enabled (the glue uses a recursive mutex and allocates nothing at runtime). TOTAL_HEAP_SIZE is 65536 in the example (lwIP and CMSIS-RTOS allocate from it).
  5. 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.
  6. Middleware → LWIP: enable it. General settings: DHCP off, static IP (example: 192.168.2.20, mask 255.255.255.0, gateway 192.168.2.1); QInsight connects to this address. Key options that XCP depends on:
    OptionValueWhy
    LWIP_SO_RCVTIMEO1the server task receives with a 1 ms timeout; that timeout is the poll cadence of the transmit pump. Without it lwip_recv blocks forever and nothing is ever transmitted.
    LWIP_TCP_KEEPALIVE1per-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.c call below)
    LWIP_SO_LINGER1the 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 gone
    LWIP_NETCONN_SEM_PER_THREAD1one lwIP semaphore per calling thread (requires configNUM_THREAD_LOCAL_STORAGE_POINTERS ≥ 1 in FreeRTOS, see the USER CODE block of the example's lwipopts.h)
    LWIP_NETCONN_FULLDUPLEX1shutdown from another context while recv is pending — used by the transmit stall recovery of the glue
    LWIP_SO_SNDTIMEO1send timeout, so that a master that stopped reading cannot block the stack
    MEM_SIZE16384lwIP heap; the XCP data socket alone needs several TCP segments in flight under DAQ load
    TCPIP_THREAD_STACKSIZE4096the lwIP thread also runs the socket API paths

    LWIP_SO_RCVTIMEO, LWIP_NETCONN_SEM_PER_THREAD, LWIP_NETCONN_FULLDUPLEX, MEM_SIZE and TCPIP_THREAD_STACKSIZE are CubeMX parameters (they are carried by the .ioc). LWIP_TCP_KEEPALIVE, LWIP_SO_LINGER and LWIP_SO_SNDTIMEO are not exposed by CubeMX — put them into the USER CODE BEGIN 1 block of LWIP/Target/lwipopts.h, guarded with #ifndef, exactly as the example does; that block survives regeneration.
  7. 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.c are wrong. The example's linker script and REGEN-NOTES.md document 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 #error in clock64.h, because a wrong value would not fail but silently produce timestamps at the wrong rate;
  • Clock64_Init() once, after SystemClock_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 moment qfw_xcp_init runs.
  • project_name and epk are the identity the master reads with GET_ID (see The version string). port is the TCP server port. log_level gates the engine and glue log lines that reach printf.
  • QfwXcpGlue_Init (in glue/freertos_lwip/xcp_sdk_glue.c) creates the recursive mutex, fills qfw_xcp_config_t (transmit, clock, critical section, log, queue memory), calls qfw_xcp_init and creates the server task. It returns false when get_clock_us is missing, the mutex cannot be created, qfw_xcp_init fails or the task cannot be created.
  • Events: create them after QfwXcpGlue_Init and before the master connects — in practice all of them at init. The first event gets id 0, the next 1, …; QInsight references the id as daqId in its events. A name is at most 15 characters; at most 16 events exist (docs/configuration.md). cycle_time_us is 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_event the 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() == false and 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 CODE sections: edit generated files only between USER 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 in freertos.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; without volatile, -Os may keep the value in a register or remove the object altogether;
  • use a fixed layout: standard-layout structs, no pointers to follow, static_assert on the member offsets you publish (the example's SineChannel pins amplitude +0, period_s +4, raw +8, value +12, bypass +16 and sizeof == 24);
  • give it a readable linker name (the example declares the C++ object with extern "C" so that it appears as gDemoSignals in 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:

  1. Green LED blinking every 500 ms — the scheduler and the default task are running. Red LED on — QfwXcpServer_Init returned false (see Troubleshooting).
  2. ping 192.168.2.20 from the PC answers — lwIP, the PHY and the static IP are right (PC in the same subnet).
  3. On the ST-LINK virtual COM port (115200 8N1; the ST BSP retargets printf to it in the example) the glue prints [XCP I] SDK server listening on TCP port 5555 at start and [XCP I] master connected when QInsight connects. With log_level >= 3 the engine also prints its start-up configuration.
  4. 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:

  1. Driver: TCP Client with ip = the board's address, port = 5555. Recommended: keepAliveMs 5000, so that the PC side also detects a lost board, and connectionTimeoutMs 1500: 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).
  2. Protocol: XCP on TCP under that driver. Keep daqTimestamps=slave to use the board's 64-bit microsecond clock as the time axis.
  3. Events: a polling event (e.g. 500 ms) for calibration values, and a DAQ event with Extra Params direction="DAQ";daqId="0" — 0 is the id returned by the first qfw_xcp_create_event in your integration file. A STIM event uses direction="STIM";daqId="0" on the same channel (see Events).
  4. 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.
  5. 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.