How much RAM does an ISO-TP stack actually need?
Ask how much memory an ISO-TP stack needs and you usually get the state machine answer: a few hundred bytes of flags, counters and timers. That is true, and it is useless when you are deciding whether a diagnostic stack fits on the part you already chose.
So we linked one and measured it. Not a synthetic benchmark — the basic_ecu_freertos example from Xaloqi EDS, a working UDS server with ISO-TP transport, 5 DIDs, 2 DTCs, 3 routines, safety wrappers and NVM-backed DTC storage, built for Cortex-M4 at -Os.
text data bss dec hex filename 23872 68 70284 94224 17010 eds_freertos.elf
23.3 KiB of flash. 68.7 KiB of RAM. The RAM figure is three times the flash figure, which is the opposite of what most people expect from a protocol stack — and the reason is worth understanding before you cut anything.
The protocol state machine really is tiny
The ISO-TP context is one static struct. Its size at link time is 4,168 bytes, of which rx_buf — the reassembly buffer — is 4,095. Subtract it and you get the number everyone quotes:
sizeof(isotp_ctx_t) = 4168 bytes rx_buf[ISOTP_RX_BUF_LEN] = 4095 bytes -------------------------------------- actual protocol state = 73 bytes
73 bytes. Sequence number, block-size and STmin state, flow-control counters, the N_WFT wait counter, timers, lengths, a couple of flags. The ISO 15765-2 state machine is not what costs you memory. The buffer it reassembles into is.
And that buffer is sized by one constant:
/* core/uds_types.h */ #define UDS_MAX_PAYLOAD_LEN (4095U) /* ISO 14229-1 PDU limit over CAN */ /* transport/isotp.h */ #define ISOTP_RX_BUF_LEN (UDS_MAX_PAYLOAD_LEN)
4,095 bytes is the largest single diagnostic message classic ISO-TP can carry, so a stack that accepts anything the standard permits must be able to hold one. That is a defensible default. It is also, as it turns out, not paid once.
Where the 68.7 KiB actually goes
Reading the symbol table rather than guessing. These are the largest RAM symbols in the linked image, from arm-none-eabi-nm --size-sort -S:
| Symbol | Bytes | What it is |
|---|---|---|
ucHeap | 16,384 | FreeRTOS heap — your FreeRTOSConfig.h, not the diagnostic stack |
s_nvm_records | 8,384 | NVM store backing DTC persistence |
s_server_ctx | 8,244 | UDS server context — holds two message buffers (request + response) |
s_isotp_ctx | 4,168 | ISO-TP context — one reassembly buffer + 73 B of state |
s_self_test_req_a | 4,098 | Safety self-test buffer A |
s_self_test_req_b | 4,098 | Safety self-test buffer B |
s_poll_req_buf | 4,098 | Application poll-loop request buffer |
s_poll_resp_buf | 4,098 | Application poll-loop response buffer |
s_periodic_frame.0 | 4,098 | Periodic-transmission frame (SID 0x2A) |
_stack_start | 4,096 | Main stack |
s_poll_stack | 2,048 | Diagnostic task stack |
s_dtc_table | 1,536 | Generated DTC table |
s_did_table | 1,536 | Generated DID table |
s_routine_table | 768 | Generated routine table |
s_transfer_ctx | 548 | Block-transfer context (SID 0x34–0x37) |
s_idle_stack | 512 | FreeRTOS idle task stack |
s_mirror_buf | 509 | DTC mirror buffer |
| …78 further symbols, all under 200 bytes: security and session contexts, keys, queue storage, task control blocks | ||
Group that and the shape is obvious:
| Category | Bytes | Share |
|---|---|---|
| Message buffers (eight of them) | 32,902 | 47% |
| FreeRTOS heap and kernel objects | ~16,750 | 24% |
| NVM store + DTC mirror | ~8,917 | 13% |
| Task and main stacks | 6,656 | 9.5% |
| Generated DID / DTC / routine tables | 3,840 | 5.5% |
| All protocol contexts and state | ~1,000 | 1.4% |
Nearly half the RAM is eight copies of the same 4 KB buffer. Every piece of actual protocol state in the stack — ISO-TP, UDS server, session, security, transfer, safety — adds up to about 1 KB, or 1.4%.
Eight buffers, and why
Each one exists for a role that can be live at the same time as the others, which is why they are not shared:
| × | Buffer | Why it cannot be shared |
|---|---|---|
| 1 | ISO-TP reassembly | Fills over many CAN frames while the previous response may still be sending |
| 2 | Server request + response | A handler reads the request while writing the response |
| 2 | Poll-loop request + response | Application-owned, handed to the server per transaction |
| 2 | Safety self-test A + B | The ASIL-B self-test compares two independently built messages; sharing one would defeat the comparison |
| 1 | Periodic frame | SID 0x2A transmits on its own schedule, unrelated to request/response |
That the count is exactly eight is not inferred from reading the code — it is measured, in the next section.
The one lever that matters
If eight buffers are each UDS_MAX_PAYLOAD_LEN bytes, then lowering that constant should cut RAM by eight times the reduction and leave flash almost untouched. Rebuilding at three values:
| UDS_MAX_PAYLOAD_LEN | Flash (text) | RAM (data+bss) | RAM saved |
|---|---|---|---|
| 4095 (default) | 23,872 | 70,352 | — |
| 1024 | 23,840 | 45,776 | 24,576 |
| 512 | 23,808 | 41,680 | 28,672 |
The arithmetic confirms the structure exactly. Dropping 4095 → 1024 saves 24,576 bytes, which is 8 × 3,072. Dropping 1024 → 512 saves a further 4,096, which is 8 × 512. Eight buffers, measured rather than assumed.
Going from 4095 to 1024 frees 24 KiB of RAM and costs 32 bytes of flash. On a part with 64 KiB of SRAM that is the difference between fitting and not fitting.
What you give up is honest and specific: your ECU can no longer receive a single diagnostic message longer than the new ceiling. For most ECUs nothing comes close — a DID read, a DTC query, a routine control are tens of bytes. The things that do are bulk operations: firmware transfer via RequestDownload (0x34), which you can chunk to your block size instead, and ReadDTCInformation (0x19) on an ECU with a large DTC table. Size the ceiling to your largest real message, not to what the standard permits.
One caveat you need to know before planning around this: UDS_MAX_PAYLOAD_LEN is currently a plain #define in core/uds_types.h with no #ifndef guard, so today you change it by editing that header rather than by passing a flag. The measurements above were taken exactly that way. Making it overridable from the build is a one-line change we would rather make deliberately than ship as a surprise.
What about stack?
Static RAM is only half the question — a stack overflow on a diagnostic task is a field failure, not a footprint line item. Those figures come from real silicon rather than a link map, and we measured them separately on a NUCLEO-H753ZI (Cortex-M7 @ 480 MHz) with the DWT cycle counter and Zephyr's k_thread_stack_space_get():
diag_task stack: used=688/4096 bytes (peak observed)
688 bytes against a 4,096-byte allocation, under a stimulus campaign of session control, DID reads and 95 near-maximum multi-frame messages. A separately traced static worst case for the same call chain is around 1,600 bytes, which is the figure to design against — the runtime campaign did not stress SecurityAccess's AES-128-CMAC path or a DTC-heavy 0x19. Either way the 4 KiB default leaves margin.
A deliberate design consequence shows up here too: uds_msg_buf_t is 4,097 bytes, and every instance in the stack is a module-level static. None is ever allocated on a stack, and a compile-time assertion fires if someone tries — which is why a 4 KB task stack is enough to run a stack whose buffers total 32 KB.
The full timing and WCET measurements — uds_server_process_request(), ISO-TP reassembly, DoIP forwarding, including a real cold-start outlier — are in docs/PERFORMANCE.md.
What these numbers are not
Footprint is one of the few embedded figures you can measure honestly without hardware: the linker has already decided. So the flash and RAM numbers above are exact for the build described, and they are not estimates. But be clear about their scope:
They are one example, one configuration, one compiler. basic_ecu_freertos with 5 DIDs, 2 DTCs and 3 routines, built with arm-none-eabi-gcc 14.2.1 at -Os for Cortex-M4. A 64-DID ECU grows the generated tables — the DID table is 1,536 bytes for 5 DIDs on this build, so the table, not the buffers, is what scales with your configuration. A different compiler version will move text by a few hundred bytes. The FreeRTOS heap and task stacks are your configuration choices, not ours.
What generalises is not the totals. It is the structure: count the full-size message buffers your stack instantiates, multiply by your payload ceiling, and you have predicted most of its RAM before you build anything. That applies to any ISO-TP implementation, not just this one.
Reproduce it
Every number above comes from these commands. The repository is public, the FreeRTOS kernel is public, the generated code is committed, and no licence is required — codegen is the commercial part, and you are not running it:
# Toolchain (Debian/Ubuntu) sudo apt-get install -y gcc-arm-none-eabi libnewlib-arm-none-eabi cmake ninja-build git clone --depth 1 https://github.com/Xaloqi/EDS.git git clone --depth 1 --branch V11.1.0 \ https://github.com/FreeRTOS/FreeRTOS-Kernel.git frk cd EDS cmake -B build \ -DCMAKE_TOOLCHAIN_FILE="$PWD/cmake/toolchain/arm-none-eabi.cmake" \ -DEDS_PLATFORM=freertos \ -DFREERTOS_DIR="$PWD/../frk" \ -DBOARD=qemu_cortex_m4 \ -DFREERTOS_CONFIG_DIR="$PWD/examples/basic_ecu_freertos/boards/qemu_cortex_m4" \ -GNinja examples/basic_ecu_freertos ninja -C build # The headline figures arm-none-eabi-size build/eds_freertos.elf # Where the RAM went — biggest symbols last arm-none-eabi-nm --size-sort -S --radix=d build/eds_freertos.elf \ | awk '$3 ~ /^[bBdD]$/'
To reproduce the payload-ceiling table, change UDS_MAX_PAYLOAD_LEN in core/uds_types.h, delete build/, and run the two commands again.
One sharp edge, since we hit it ourselves: FREERTOS_DIR must be an absolute path, which is why the command above wraps it in $PWD. Pass a relative one and the build now says so directly and names the fix. It did not when this article was written — you got Cannot find source file: ../frk/tasks.c followed by a puzzling No SOURCES given to target, because CMake's if(EXISTS) is only well-defined for absolute paths, so the build's own "Cannot find FreeRTOS port" check never fired. Running our own published commands from a clean clone is what caught that, and it is now fixed upstream. The sequence reproduces the figures above byte for byte.
pip install xaloqi-tester then xaloqi-sim --demo.
xaloqi-testlab-core on GitHub →