All posts
Tutorial · · 10 min read

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

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:

  1. Service 0x10 registered — yes
  2. Extended session allowed for 0x10 — yes
  3. Security level required — none (session control is always open)
  4. Access permitted — yes
  5. 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.