Getting started with UDS diagnostics on Zephyr RTOS in 30 minutes
This tutorial walks you from a fresh Zephyr workspace to a running UDS server responding to real diagnostic requests — in about 30 minutes. We use QEMU so you need no hardware at all, and we test with Python so there is no CANoe or Kvaser required.
By the end you will have: a diagnostics_config.yaml describing a minimal ECU, a generated C UDS stack integrated into a Zephyr application, the application running in QEMU, and a Python script that sends a ReadDataByIdentifier request and gets a response back.
What we're building
A Body Control Module (BCM) with three UDS services — session control, TesterPresent keepalive, and DID read. The ECU accepts requests over a virtual CAN interface. The diagnostic client is a 15-line Python script.
The runtime is the EDS GPL v2 runtime — always free on GitHub. The codegen step (YAML → C) requires a Developer license. If you want to follow along without a license, skip to the Manual integration path section below where we wire the handlers by hand.
Prerequisites
- Zephyr SDK 0.16+ and
westinstalled (Zephyr getting started guide) - Python 3.10+ with
pip can-utilspackage for the virtual CAN interface (apt install can-utils)- Linux host — QEMU networking with virtual CAN works cleanly on Linux; WSL2 works with caveats
Check your Zephyr installation before starting:
west --version # expect: west 1.2 or later
zephyr-sdk-setup.sh --version # expect: 0.16.x
Step 1 — Set up a Zephyr workspace with EDS (5 min)
Create a new west workspace and add EDS as a module:
mkdir ~/uds-tutorial && cd ~/uds-tutorial
west init -m https://github.com/zephyrproject-rtos/zephyr --mr v3.6.0
west update
Add EDS to west.yml in the projects list:
# ~/uds-tutorial/zephyr/west.yml (add inside the projects list)
- name: eds
url: https://github.com/Xaloqi/EDS
revision: v1.9.0
path: modules/eds
west update # pulls the EDS module
EDS registers itself as a Zephyr module via zephyr/module.yml in the repo root. After west update, CONFIG_EDS is available in Kconfig.
Step 2 — Create your ECU description (5 min)
EDS describes an ECU in a single YAML file. Create the application directory and the config:
mkdir -p ~/uds-tutorial/app/src
cd ~/uds-tutorial/app
# ~/uds-tutorial/app/diagnostics_config.yaml
ecu:
name: bcm
platform: zephyr
addressing:
can_id_request: 0x7DF # functional address (all ECUs)
can_id_response: 0x7E8 # physical response address
sessions:
- default
- extended
services:
- id: 0x10 # DiagnosticSessionControl
- id: 0x11 # ECUReset
- id: 0x22 # ReadDataByIdentifier
- id: 0x27 # SecurityAccess
- id: 0x19 # ReadDTCInformation
- id: 0x14 # ClearDiagnosticInformation
- id: 0x3E # TesterPresent
dids:
- id: 0xF190
name: VIN
length: 17
read_sessions: [default, extended]
- id: 0xF18C
name: ECU_Serial
length: 8
read_sessions: [default, extended]
- id: 0xD001
name: Odometer_km
length: 4
read_sessions: [extended]
security:
level_1:
algorithm: aes128_cmac
key_source: hardcoded # for tutorial only — use provisioned key in production
This gives you a minimal but complete ECU: two sessions, seven services, three DIDs, and AES-128-CMAC SecurityAccess. Every service is gated automatically by session and security level — you do not write dispatch logic by hand.
Step 3 — Generate the C stack (2 min)
This step requires a Developer license. Skip to Step 3b if you are following the manual path.
pip install eds-tools # installs codegen, testgen, and AI tools
python3 -m eds.codegen \
--config diagnostics_config.yaml \
--out src/generated/ \
--safety-wrappers \
--asil-level B \
--test-gen
Output in src/generated/:
src/generated/
uds_server.c / uds_server.h # main UDS dispatcher + Zephyr thread
uds_dids.c / uds_dids.h # DID read/write stubs — fill in your sensor reads
uds_dtcs.c / uds_dtcs.h # DTC storage (settings subsystem, NVM-backed)
uds_sessions.c / uds_sessions.h
uds_security.c / uds_security.h # AES-128-CMAC seed/key generation
uds_safety.c / uds_safety.h # ASIL-B 5-step validation chain
test_uds.py # pytest suite — runs in CI against QEMU
The DID stubs in uds_dids.c are the only files you edit. They look like this:
/* uds_dids.c — generated stub, fill in your sensor read */
eds_rc_t uds_did_read_F190(uint8_t *buf, uint16_t *len)
{
/* TODO: read VIN from NVM */
static const uint8_t vin[17] = "1HGCM82633A123456";
memcpy(buf, vin, 17);
*len = 17;
return EDS_OK;
}
Everything else — session gating, NRC generation, ISO-TP framing, P2 timers — is handled by the runtime.
Step 3b — Manual integration path (no license)
If you are using the GPL runtime directly, create src/generated/uds_dids.c and implement the handler table manually using the eds_did_register() API documented in modules/eds/include/eds/did.h. The runtime expects the same function signatures; you are just writing what codegen would have produced. The basic example ECU in modules/eds/examples/basic/ shows the full manual wiring.
Step 4 — Zephyr application files (8 min)
Three files complete the integration.
prj.conf
# ~/uds-tutorial/app/prj.conf
CONFIG_CAN=y
CONFIG_CAN_ISOTP=y
CONFIG_NETWORKING=n
CONFIG_EDS=y
CONFIG_EDS_ISOTP=y
CONFIG_EDS_SESSION_TIMEOUT_MS=5000
CONFIG_EDS_DID_MAX=16
CONFIG_EDS_DTC_MAX=32
CONFIG_EDS_SECURITY_LOCKOUT_ATTEMPTS=3
CONFIG_SETTINGS=y
CONFIG_SETTINGS_RUNTIME=y
CONFIG_HEAP_MEM_POOL_SIZE=8192
CONFIG_MAIN_STACK_SIZE=2048
CMakeLists.txt
# ~/uds-tutorial/app/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(bcm_uds)
target_sources(app PRIVATE
src/main.c
src/generated/uds_server.c
src/generated/uds_dids.c
src/generated/uds_dtcs.c
src/generated/uds_sessions.c
src/generated/uds_security.c
src/generated/uds_safety.c
)
target_include_directories(app PRIVATE src/generated/)
src/main.c
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/can.h>
#include "uds_server.h"
int main(void)
{
const struct device *can_dev = DEVICE_DT_GET(DT_CHOSEN(zephyr_canbus));
if (!device_is_ready(can_dev)) {
return -ENODEV;
}
/* eds_init() starts the UDS thread and attaches the ISO-TP filter */
eds_init(can_dev);
/* Main thread has nothing else to do — UDS runs in its own k_thread */
return 0;
}
That is the entire application. eds_init() creates a Zephyr thread, registers the ISO-TP CAN filter for your configured request address (0x7DF), and enters the receive loop. The generated handlers are called from inside that thread — no callbacks into your code, no ISR context issues.
Step 5 — Build and run in QEMU (5 min)
Set up the virtual CAN interface that QEMU and the Python client will share:
sudo modprobe vcan
sudo ip link add dev vcan0 type vcan
sudo ip link set up vcan0
Build for QEMU with CAN support:
cd ~/uds-tutorial/app
west build -b qemu_x86 -- -DCONFIG_QEMU_ICOUNT=n
Run it:
west build -t run
You should see in the terminal:
[00:00:00.001,000] <inf> eds: EDS v1.9.0 initialised
[00:00:00.001,000] <inf> eds: ISO-TP filter registered (RX: 0x7DF TX: 0x7E8)
[00:00:00.001,000] <inf> eds: UDS thread running
Leave this running. Open a second terminal for the client.
Step 6 — Send your first UDS request (5 min)
Install the Python ISO-TP and UDS libraries:
pip install python-can python-isotp udsoncan
Create test_first_request.py:
import can
import isotp
import udsoncan
from udsoncan.connections import PythonIsoTpConnection
from udsoncan.client import Client
import udsoncan.services as services
# Connect over vcan0
bus = can.interface.Bus('vcan0', bustype='socketcan')
tp_addr = isotp.Address(isotp.AddressingMode.Normal_11bits,
rxid=0x7E8, txid=0x7DF)
stack = isotp.CanStack(bus, address=tp_addr)
conn = PythonIsoTpConnection(stack)
with Client(conn) as client:
# 1. Open extended session
client.change_session(services.DiagnosticSessionControl.Session.extendedDiagnosticSession)
print("Session: extended ✓")
# 2. Read VIN (DID 0xF190)
resp = client.read_data_by_identifier([0xF190])
vin = resp.service_data.values[0xF190]
print(f"VIN (0xF190): {vin.decode('ascii')}")
# 3. Read ECU serial (DID 0xF18C)
resp = client.read_data_by_identifier([0xF18C])
serial = resp.service_data.values[0xF18C]
print(f"ECU serial (0xF18C): {serial.hex()}")
# 4. Send TesterPresent to keep the session alive
client.tester_present(suppress_response=True)
print("TesterPresent sent ✓")
python3 test_first_request.py
Expected output:
Session: extended ✓
VIN (0xF190): 1HGCM82633A123456
ECU serial (0xF18C): 0000000000000001
TesterPresent sent ✓
You just exchanged your first UDS frames with a Zephyr ECU.
What happened under the hood
The Python client sent a 10 03 (DiagnosticSessionControl — extendedDiagnosticSession) frame over ISO-TP on vcan0. The EDS ISO-TP receive loop in the Zephyr UDS thread woke up, reassembled the frame, ran the ASIL-B 5-step validation chain:
- Service 0x10 registered — yes
- Extended session allowed for 0x10 — yes
- Security level required — none (session control is always open)
- Access permitted — yes
- Data length correct — yes (single byte sub-function)
Then called the session state machine, which transitioned to extended and sent 50 03 00 19 01 F4 (positive response with P2 and P2* timing parameters).
The ReadDataByIdentifier request for 0xF190 went through the same chain, confirmed the DID exists and is readable in extended session, called uds_did_read_F190(), and returned the 17-byte VIN in the positive response.
Next steps
Replace the stub implementations
Open src/generated/uds_dids.c and replace the hardcoded values with real sensor reads. For Odometer, that might be a read from the settings subsystem or a CAN message from the powertrain network. EDS does not care — it calls your function and wraps the return value in a UDS positive response.
Add SecurityAccess
The config has level_1: aes128_cmac. The generated uds_security.c contains a uds_security_compute_key() stub. Replace the hardcoded seed-to-key derivation with your OEM algorithm. The lockout after 3 failed attempts is already wired up in the runtime.
Automate your tests
The codegen also produced test_uds.py — a full pytest suite that covers all DIDs, all session transitions, and all expected NRC responses. Run it against QEMU in CI:
pytest test_uds.py --vcan vcan0
This is the same approach we described in UDS campaigns in CI without hardware. No board, no Kvaser, no CANoe — the QEMU instance and the pytest suite run as a standard CI job.
Switch to real hardware
Replace -b qemu_x86 with your board target (e.g. -b stm32f7_disco or -b nxp_frdm_mcxn947) and add a board overlay that maps the CAN peripheral. The EDS runtime does not change — the Zephyr CAN driver abstraction handles the rest.
Add DoIP for Ethernet-connected ECUs
Change CONFIG_EDS_ISOTP=y to CONFIG_EDS_DOIP=y, add the Ethernet Kconfig, and the same UDS handlers now accept requests over DoIP (ISO 13400-2) instead of CAN. No handler code changes needed — transport is a configuration choice. For the full walkthrough see CAN to DoIP: migrating an EDS ECU to Ethernet.
Troubleshooting
No response from the ECU
Check the vcan0 interface is up: ip link show vcan0 — state should be UNKNOWN (not DOWN). Check the QEMU CAN interface is mapped to vcan0: add -object can-bus,id=canbus0 -device kvaser_pci,canbus=canbus0 -object can-host-socketcan,id=canhost0,if=vcan0,canbus=canbus0 to the QEMU command line if using a custom runner. For west-managed QEMU this is handled by the board definition.
NRC 0x7F 0x22 0x31 (requestOutOfRange) on DID read
The DID ID in your request (e.g. 0xF191) is not in diagnostics_config.yaml. The ASIL-B validation step 1 catches unknown DIDs before the handler is ever called.
NRC 0x7F 0x22 0x22 (conditionsNotCorrect) on DID read
Odometer (0xD001) is configured as read_sessions: [extended] only. You are reading it in default session. Open extended session first (10 03), then retry.
python-can cannot find vcan0
Your user may not have CAN socket permissions. Add yourself to the netdev group or run with sudo. On some distributions: sudo setcap cap_net_raw+ep $(which python3).
The full source for this tutorial is in modules/eds/examples/basic/ in the EDS repository. The Zephyr UDS landing page covers the complete service and transport matrix for production ECU builds. If you have questions, open an issue on GitHub or email contact@xaloqi.com.