All posts
Engineering · · 9 min read

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:

Largest RAM symbols. 95 symbols account for 70,321 of the 70,352 bytes of data + bss; the remaining 31 bytes are section padding.
SymbolBytesWhat it is
ucHeap16,384FreeRTOS heap — your FreeRTOSConfig.h, not the diagnostic stack
s_nvm_records8,384NVM store backing DTC persistence
s_server_ctx8,244UDS server context — holds two message buffers (request + response)
s_isotp_ctx4,168ISO-TP context — one reassembly buffer + 73 B of state
s_self_test_req_a4,098Safety self-test buffer A
s_self_test_req_b4,098Safety self-test buffer B
s_poll_req_buf4,098Application poll-loop request buffer
s_poll_resp_buf4,098Application poll-loop response buffer
s_periodic_frame.04,098Periodic-transmission frame (SID 0x2A)
_stack_start4,096Main stack
s_poll_stack2,048Diagnostic task stack
s_dtc_table1,536Generated DTC table
s_did_table1,536Generated DID table
s_routine_table768Generated routine table
s_transfer_ctx548Block-transfer context (SID 0x34–0x37)
s_idle_stack512FreeRTOS idle task stack
s_mirror_buf509DTC 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:

CategoryBytesShare
Message buffers (eight of them)32,90247%
FreeRTOS heap and kernel objects~16,75024%
NVM store + DTC mirror~8,91713%
Task and main stacks6,6569.5%
Generated DID / DTC / routine tables3,8405.5%
All protocol contexts and state~1,0001.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:

×BufferWhy it cannot be shared
1ISO-TP reassemblyFills over many CAN frames while the previous response may still be sending
2Server request + responseA handler reads the request while writing the response
2Poll-loop request + responseApplication-owned, handed to the server per transaction
2Safety self-test A + BThe ASIL-B self-test compares two independently built messages; sharing one would defeat the comparison
1Periodic frameSID 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:

Same source, same flags, same example. Only UDS_MAX_PAYLOAD_LEN changed.
UDS_MAX_PAYLOAD_LENFlash (text)RAM (data+bss)RAM saved
4095 (default)23,87270,352—
102423,84045,77624,576
51223,80841,68028,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.


Want to drive one of these ECUs before you size anything? The free tier runs a full UDS campaign against a simulated server with no hardware and no licence — pip install xaloqi-tester then xaloqi-sim --demo. xaloqi-testlab-core on GitHub →