UDS on FreeRTOS — a different integration problem than Zephyr
Zephyr ships with a CAN driver model, an ISO-TP subsystem, a networking stack, and a flash partition API. When you integrate a UDS stack on Zephyr, most of the OS interfaces you need already exist. The integration work is mostly configuration.
FreeRTOS is different. FreeRTOS gives you a scheduler, queues, semaphores, and timers. Everything else — CAN drivers, NVM access, networking — you bring yourself. It's not a gap in FreeRTOS; it's the design. FreeRTOS targets constrained microcontrollers where you know your hardware and you don't want an OS that assumes things about it.
This post is about what that means for integrating a production-grade UDS stack on FreeRTOS, and how EDS handles the parts that Zephyr normally does for you.
The four callbacks
EDS uses a platform abstraction layer that reduces the RTOS dependency to four callbacks:
/* platform_api.h */ typedef int (*eds_can_send_fn)(const uint8_t *data, size_t len, uint32_t can_id); typedef int (*eds_can_recv_fn)(uint8_t *buf, size_t buf_len, uint32_t *can_id, uint32_t timeout_ms); typedef int (*eds_nvm_read_fn)(uint32_t addr, uint8_t *buf, size_t len); typedef int (*eds_nvm_write_fn)(uint32_t addr, const uint8_t *buf, size_t len);
On Zephyr, these are filled in by the platform layer automatically — the Zephyr bindings in platform/zephyr/ implement them using can_send(), can_recv(), flash_read(), and flash_write() from the Zephyr API.
On FreeRTOS, you implement them. That's the integration work. Everything else — the UDS session state machine, the security manager, the DID dispatch, the ASIL-B safety chain, the DTC persistence, the ISO-TP framing — runs identically on both platforms once those four callbacks are wired up.
The CAN callback
On an STM32 using HAL:
static int can_send_impl(const uint8_t *data, size_t len, uint32_t can_id)
{
CAN_TxHeaderTypeDef hdr = {
.StdId = can_id,
.IDE = CAN_ID_STD,
.RTR = CAN_RTR_DATA,
.DLC = (uint32_t)len,
};
uint32_t mailbox;
HAL_StatusTypeDef ret = HAL_CAN_AddTxMessage(&hcan1, &hdr, (uint8_t *)data, &mailbox);
return (ret == HAL_OK) ? 0 : -1;
}
static int can_recv_impl(uint8_t *buf, size_t buf_len, uint32_t *can_id, uint32_t timeout_ms)
{
CAN_RxHeaderTypeDef hdr;
uint8_t raw[8];
/* Block on a FreeRTOS queue fed by HAL_CAN_RxFifo0MsgPendingCallback */
if (xQueueReceive(can_rx_queue, &raw, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) {
return -1;
}
*can_id = hdr.StdId;
memcpy(buf, raw, hdr.DLC);
return (int)hdr.DLC;
}
The CAN interrupt fills can_rx_queue via xQueueSendFromISR(). can_recv_impl blocks on that queue with a timeout. EDS calls can_recv_impl from a dedicated FreeRTOS task with the task's stack. The UDS server task blocks waiting for CAN frames, processes them when they arrive, and sends responses through can_send_impl.
The important property: EDS doesn't call can_recv_impl from an ISR. It calls it from a task. Your CAN interrupt puts frames on a queue. The UDS task reads from that queue. This is the standard FreeRTOS pattern and it works regardless of which CAN peripheral or HAL you're using.
The NVM callback
DTC persistence requires non-volatile storage. On Zephyr, the NVM callbacks use the Zephyr flash API. On FreeRTOS, you implement them against whatever storage your hardware provides.
On a Cortex-M device with internal flash:
static int nvm_read_impl(uint32_t addr, uint8_t *buf, size_t len)
{
/* Internal flash is memory-mapped — direct read */
memcpy(buf, (void *)(UDS_NVM_BASE_ADDR + addr), len);
return 0;
}
static int nvm_write_impl(uint32_t addr, const uint8_t *buf, size_t len)
{
HAL_FLASH_Unlock();
for (size_t i = 0; i < len; i += 8) {
uint64_t word;
memcpy(&word, buf + i, 8);
HAL_StatusTypeDef ret = HAL_FLASH_Program(
FLASH_TYPEPROGRAM_DOUBLEWORD,
UDS_NVM_BASE_ADDR + addr + i,
word);
if (ret != HAL_OK) {
HAL_FLASH_Lock();
return -1;
}
}
HAL_FLASH_Lock();
return 0;
}
EDS calls nvm_write_impl only from the UDS task — never from an ISR, never concurrently. Flash write timing (HAL_FLASH_Program blocks until the write completes) is compatible with the task-based model. The UDS task is not a hard real-time task; it can tolerate the flash write latency.
If your hardware uses external flash or EEPROM, the implementation changes but the interface doesn't. EDS calls nvm_read_impl and nvm_write_impl at a rate proportional to DTC state changes — not on every UDS request. In practice, NVM writes happen when a DTC status changes (fault detected, fault cleared, DTC cleared by tester). The write frequency is low enough that even slow I2C EEPROM is viable.
The UDS task
On Zephyr, EDS uses a Zephyr kernel thread. On FreeRTOS, you create a FreeRTOS task:
void uds_task(void *arg)
{
(void)arg;
/* Register platform callbacks */
eds_platform_t platform = {
.can_send = can_send_impl,
.can_recv = can_recv_impl,
.nvm_read = nvm_read_impl,
.nvm_write = nvm_write_impl,
};
eds_platform_register(&platform);
/* Initialize the UDS server (runs uds_safety_self_test internally) */
uds_status_t ret = uds_server_init();
configASSERT(ret == UDS_STATUS_OK);
/* Run the server loop */
for (;;) {
uds_server_poll();
}
}
/* In your application startup: */
xTaskCreate(uds_task, "UDS", UDS_TASK_STACK_SIZE, NULL, UDS_TASK_PRIORITY, NULL);
uds_server_poll() blocks inside can_recv_impl until a CAN frame arrives (up to the configured ISO-TP timeout), then processes it and returns. The task never busy-waits. From FreeRTOS's perspective, the UDS task is a normal blocking task that wakes when the CAN queue has data.
The stack size for uds_task needs to cover the deepest UDS call path. The worst-case path for the EDS stack — a multi-frame write with SecurityAccess — is statically analyzable. UDS_TASK_STACK_SIZE is a compile-time constant derived from the codegen's worst-case analysis. The FreeRTOS integration guide documents the calculation.
What QEMU gives you for development
The FreeRTOS example in EDS targets qemu_cortex_m4 — QEMU's Cortex-M4 emulator. The CAN callbacks use a QEMU virtual CAN device. You can build, run, and run the full UDS campaign against the FreeRTOS example without real hardware:
cmake -B build_freertos \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/arm-none-eabi.cmake \
-DEDS_PLATFORM=freertos \
-DFREERTOS_DIR=/opt/freertos-kernel \
-DBOARD=qemu_cortex_m4 \
-GNinja \
examples/basic_ecu_freertos
ninja -C build_freertos
# Run in QEMU
qemu-system-arm \
-machine lm3s6965evb \
-nographic \
-semihosting \
-kernel build_freertos/basic_ecu_freertos.elf
For the CAN transport in QEMU, the example uses a virtual CAN socket connected to a vcan interface on the host. TestLab connects via SocketCanBus("vcan0") and runs the same campaign it would run against a real ECU.
This is the CI setup in the EDS pipeline — the FreeRTOS ARM build runs in QEMU, the TestLab campaign runner connects to the vcan socket, and the campaign must pass before the commit lands. The same binary that CI verifies is what you flash to your target hardware on your bench.
The porting checklist
What you need to port EDS to a new FreeRTOS target:
1. CAN peripheral driver — implement can_send_impl and can_recv_impl. The CAN interrupt puts frames on a FreeRTOS queue. The recv callback reads from that queue.
2. NVM storage — implement nvm_read_impl and nvm_write_impl against your storage peripheral (internal flash, external flash, EEPROM).
3. Task creation — create the UDS task with the required stack size. The stack size is documented per-example.
4. Linker script — ensure the UDS NVM region (UDS_NVM_BASE_ADDR) maps to a valid, writable flash sector that isn't overwritten by firmware updates.
5. AES-128-CMAC — the SecurityAccess implementation uses AES-128-CMAC. On Cortex-M4+, this runs in software (about 150µs per key derivation). On Cortex-M7 with the hardware crypto accelerator, you can optionally route the AES call through the HAL — the crypto abstraction is a single function pointer in platform_api.h.
That's the list. There's no Zephyr driver model to replicate, no DTS to write, no Kconfig to set. If you can send a CAN frame and read from flash, you can run EDS.
What's harder on FreeRTOS
Stack usage analysis. Zephyr has CONFIG_THREAD_STACK_INFO and k_thread_stack_space_get(). FreeRTOS has uxTaskGetStackHighWaterMark(). Both tell you the actual stack high-water mark at runtime. But for ASIL-B, you need a static upper bound, not a runtime measurement. On Zephyr, the Zephyr stack is a compile-time CMake constant documented in the Zephyr example. On FreeRTOS, UDS_TASK_STACK_SIZE is documented in the integration guide with a derivation that starts from the codegen's worst-case call depth analysis. You need to verify this against your specific configuration — DIDs with large data lengths have longer worst-case paths than DIDs with short data lengths.
RTOS tick rate. EDS uses the can_recv_impl timeout parameter for ISO-TP N_Bs and N_Cr timing. On FreeRTOS, pdMS_TO_TICKS(timeout_ms) converts milliseconds to ticks. If your FreeRTOS tick rate is 100Hz (10ms/tick), the smallest timeout you can express is 10ms. The ISO-TP timing requirements have minimum values well above that, so this is fine in practice — but verify that configTICK_RATE_HZ is at least 100Hz.
Flash write latency and scheduling. If nvm_write_impl blocks for more than a few milliseconds, other FreeRTOS tasks are not affected (the write runs in the UDS task, not in an ISR). But if your NVM write is very slow (several hundred milliseconds for EEPROM page writes), the UDS server will miss incoming CAN frames during the write. The ISO-TP N_Cs timer at the tester will expire and the session will abort. Size your NVM writes so they complete within the ISO-TP timing margins, or use deferred writes with a separate NVM task.
Same stack, different scheduler
The practical summary: EDS on FreeRTOS and EDS on Zephyr run the same C code. The UDS core (core/), the transport (transport/), the generated wrappers — all identical. What differs is eight lines of application code: four callback implementations, one task creation, and the includes for your CAN and flash HAL.
If you've already built EDS on Zephyr and need to support a FreeRTOS variant — which is increasingly common as programs start requiring RTOS choice flexibility — the migration is those eight lines plus whatever time your CAN and flash HAL integration takes on your specific hardware.
The FreeRTOS example in EDS is the working baseline. Fork it, replace the QEMU platform callbacks with your HAL, and you're running on your target.
basic_ecu_freertos example are included with the Developer tier. See pricing →