From CAN to DoIP — what the transport change actually involves
The automotive diagnostics world is mid-transition. OBD-II is CAN. UDS was designed for CAN. Every diagnostic tool that talks to a production ECU today almost certainly uses ISO-TP over CAN as its transport. But new vehicle architectures — central compute, zonal ECUs, Ethernet backbones — route diagnostics over TCP/IP via DoIP (ISO 13400). If your ECU is destined for a platform that uses an Ethernet backbone, you need DoIP support. And if you've already built your UDS stack for CAN, you need to understand what changes and what doesn't.
This post is about that migration in the context of Zephyr RTOS and EDS. Not the theory — the specific protocol machinery that changes when you swap the transport layer, and how EDS handles it.
What DoIP actually is
DoIP (Diagnostic communication over Internet Protocol, ISO 13400-2) is a protocol that carries UDS messages over TCP/IP. The UDS payload is identical. What changes is the framing and routing layer below it.
With CAN, the framing is ISO-TP: a CAN frame is 8 or 64 bytes. ISO-TP segments longer messages and reassembles them at the receiver. There's no connection concept. A tester sends to a CAN ID; the ECU listens on a CAN ID.
With DoIP, there's an actual TCP connection. Before any UDS message can be exchanged, a routing activation procedure establishes that the tester is authorized to send diagnostics through the DoIP gateway to the target ECU. After that, each UDS message travels inside a DoIP DiagnosticMessage PDU.
The DoIP handshake sequence:
1. Tester opens TCP connection to DoIP server (port 13400) 2. Tester sends RoutingActivationRequest (payload_type 0x0005) — includes tester logical address and activation type 3. DoIP server responds RoutingActivationResponse — code 0x10: routing successfully activated 4. Tester sends DiagnosticMessage (payload_type 0x8001) — contains the UDS request PDU 5. DoIP server sends DiagnosticMessagePositiveAcknowledgement (0x8002) — confirms message received, routing to target ECU 6. ECU processes UDS request, sends response 7. DoIP server forwards response in DiagnosticMessage back to tester
Step 6 is where your existing UDS stack runs. Steps 1–5 and step 7 are DoIP. The UDS session state machine, the DID dispatch, the ASIL-B safety chain, the DTC persistence — none of that changes. What changes is the transport layer beneath it.
What doesn't change in your UDS stack
In EDS, the UDS core (core/) is completely transport-agnostic. The service handlers call a platform function uds_transport_send() and receive calls from uds_transport_receive(). Those functions are thin wrappers around the active transport implementation.
For CAN, those wrappers call the ISO-TP state machine in transport/isotp.c, which drives the CAN peripheral via platform_can_send() and platform_can_recv().
For DoIP, they call the DoIP server in transport/doip/, which drives a TCP socket via Zephyr's zsock_send() / zsock_recv() (or FreeRTOS+LwIP's equivalent).
The same diagnostics_config.yaml produces working code for both transports. The same generated did_safety_wrappers.c runs under both transports. The codegen doesn't know or care which transport you're using — the generated code is transport-agnostic.
This isn't an abstraction claim. It's a structural constraint. The EDS CI pipeline runs the same DoIP example on native_sim (Zephyr's loopback Ethernet interface) as part of every commit verification. If a change to the UDS core broke the DoIP transport path, CI catches it before the branch merges.
The DoIP server implementation
The DoIP server lives in transport/doip/. It handles:
Routing Activation — Before a tester can send diagnostic messages, it must activate routing. The DoIP server validates the request, checks the tester logical address, and responds with one of the defined activation response codes. EDS currently supports activation type 0x00 (default) and returns 0x10 (routing successfully activated) on success.
DiagnosticMessage dispatch — Each 0x8001 payload received on the TCP connection is unwrapped, the target ECU logical address is checked, and the UDS payload is handed to the UDS core for processing. The response is wrapped back into a 0x8001 PDU and sent to the tester.
DiagnosticMessagePositiveAcknowledgement / NegativeAcknowledgement — DoIP requires an immediate acknowledgement before the UDS response arrives. The server sends 0x8002 (positive) or 0x8003 (negative, with reason code) after receiving the diagnostic message but before the UDS processing completes.
Alive Check — DoIP connections use a heartbeat. The server responds to VehicleIdentification requests and handles the AliveCheck mechanism that detects stale connections.
The Zephyr binding uses zsock_* for socket operations — Zephyr's POSIX-compatible socket API. The FreeRTOS binding uses LwIP's lwip_send() / lwip_recv() directly. Both bindings implement the same C interface: doip_server_init(), doip_server_poll(), doip_server_close().
The basic_ecu_doip example
The examples/basic_ecu_doip/ example is a minimal DoIP ECU. Same DID table as basic_ecu. Same DTC table. Same ASIL-B wrappers. Different board config and transport binding.
On Zephyr, the native_sim target runs the DoIP server on a loopback Ethernet interface. Build and run:
python3 tools/codegen.py \
--config examples/basic_ecu_doip/diagnostics_config.yaml \
--out examples/basic_ecu_doip/generated/ \
--safety-wrappers --asil-level B --test-gen
west build -b native_sim examples/basic_ecu_doip \
-- -DDTC_OVERLAY_FILE=boards/native_sim.overlay
west build -t run
The ECU is now listening on 127.0.0.1:13400. Connect from TestLab using DoipBus:
from xaloqi.tester import UdsTester
from xaloqi.tester.transport.doip import DoipBus
async with UdsTester(
DoipBus("127.0.0.1"),
rx_id=0xE400,
tx_id=0x0E00,
) as ecu:
await ecu.session(Session.EXTENDED)
await ecu.security_access(level=1)
vin = await ecu.read_did(0xF190)
print(f"VIN: {vin.data.decode()}")
DoipBus handles the routing activation automatically. From UdsTester's perspective, DoipBus is just another transport — the API is identical to passing a SocketCanBus or VirtualBus.
FreeRTOS + LwIP
The basic_ecu_doip_freertos example targets any FreeRTOS MCU with LwIP — STM32H7, i.MX RT, or similar. The DoIP transport binding uses LwIP's API instead of Zephyr's socket layer.
python3 tools/codegen.py \
--config examples/basic_ecu_doip_freertos/diagnostics_config.yaml \
--out examples/basic_ecu_doip_freertos/generated/ \
--safety-wrappers --asil-level B --no-manifest
cmake -B build_doip_freertos \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/arm-none-eabi.cmake \
-DEDS_PLATFORM=freertos \
-DEDS_TRANSPORT=doip \
-DFREERTOS_DIR=/opt/freertos-kernel \
-DLWIP_DIR=/opt/lwip \
-DBOARD=stm32h743 \
-GNinja \
examples/basic_ecu_doip_freertos
ninja -C build_doip_freertos
The DoIP/LwIP binding doesn't depend on any Zephyr subsystem. LwIP 2.x is the only Ethernet dependency. The rest of the stack — UDS core, ASIL-B chain, DTC persistence — is identical between the Zephyr and FreeRTOS variants.
What DoIP doesn't change: the ASIL-B properties
Every ASIL-B requirement from ISO 26262 Part 6 that applies to the CAN transport variant applies equally to the DoIP variant:
No dynamic memory — the DoIP server uses statically declared buffers. TCP socket handles are Zephyr or LwIP socket descriptors, not heap-allocated objects. The DoIP PDU reassembly buffer is a compile-time constant (DOIP_MAX_PAYLOAD_SIZE).
No recursion — the DoIP server is a poll-loop handler. doip_server_poll() is called from the EDS thread, processes one PDU per call, returns. No recursive dispatch.
Pre-start self-test — uds_safety_self_test() runs before the DoIP server accepts any connection. If the self-test fails, the server never opens the listening socket. The generated init sequence is identical for DoIP and CAN variants.
Safety violation accounting — every rejected UDS request that passes through the DoIP transport is counted by the same safety module. The transport layer is beneath the safety module. A tester that sends a malformed DiagnosticMessage gets a NegativeAcknowledgement from the DoIP layer before the UDS core sees the payload. A tester that sends a valid DiagnosticMessage but a UDS request that fails a safety gate gets an NRC from the UDS core, and the violation is counted.
What DoIP does change: your network threat model
CAN is a broadcast bus. Every node on the bus receives every frame. Physical access to the CAN bus is a prerequisite for sending diagnostic frames. This shapes the threat model for CAN-based UDS: physical access is assumed to be controlled.
Ethernet changes this. A DoIP server listening on port 13400 is reachable by anything on the same IP network. In a vehicle Ethernet backbone, "the same network" might include the infotainment system, the telematics module, and anything that connects to the OBD port via a gateway. The DoIP Routing Activation mechanism is the first layer of access control — a tester that can't complete routing activation can't send diagnostic messages. But that's a protocol-layer control, not a network-layer control.
This is out of scope for EDS to solve — EDS implements the DoIP protocol correctly, including routing activation. Whether your gateway or firewall restricts which network nodes can reach port 13400 on your ECU is a system-level architecture question, not a diagnostics-stack question.
The same applies to the AES-128-CMAC SecurityAccess mechanism. EDS ships with placeholder keys and a compile-time gate that prevents deployment with those keys. The OEM key injection procedure — how you get production keys into the ECU securely — is documented in the Security Integration Guide included with the Professional tier.
The migration path in practice
If you have an EDS-based UDS stack running on CAN and need to add DoIP:
1. Add the DoIP transport dependency to your CMake build (-DEDS_TRANSPORT=doip).
2. Configure Zephyr networking (CONFIG_NETWORKING=y, CONFIG_NET_TCP=y) or LwIP for FreeRTOS.
3. Change the platform binding from CAN callbacks to socket initialization in your application code.
4. Rebuild. The generated UDS code is unchanged.
There's no re-generation step. There's no change to the ASIL-B wrappers. The DID table, the DTC table, the session state machine — nothing in the UDS core changes. The five-step access chain runs against the same generated wrappers as before, whether the UDS PDU arrived over CAN or over a TCP socket from a DoIP tester.
That's what "transport-agnostic core" means in practice. Not a diagram property. A verifiable claim: the EDS CI pipeline builds the CAN and DoIP variants from the same YAML, runs the same campaign against both, and requires both to pass before any commit lands.