ASIL-B UDS diagnostics on Zephyr RTOS — how EDS enforces safety properties at build time
Most UDS implementations on Zephyr are built for one project, by one team, under schedule pressure. They work. They are not ASIL-B — and retrofitting safety properties into a running diagnostics stack costs more than building them in from the start.
Getting from "the UDS stack passes integration tests" to "the UDS stack satisfies ISO 26262 Part 6" requires a specific set of architectural choices that are very hard to retrofit. This post describes exactly what those choices are, why they matter, and how we implemented them in Xaloqi EDS — an open-source ISO 14229 UDS stack for Zephyr RTOS and FreeRTOS, built around YAML-driven code generation and ISO-TP transport.
What ASIL-B actually requires from a diagnostics stack
ISO 26262 Part 6 (Software) at ASIL-B doesn't mandate a particular implementation — it mandates properties. The ones that bite UDS implementations most often:
No dynamic memory. ASIL-B software cannot use malloc, calloc, or free. This rules out every UDS library that builds DID and DTC tables at runtime from linked lists. All buffers must be statically declared. All table sizes must be compile-time constants.
No recursion. The call stack depth must be statically analyzable. Any recursive service dispatch — even indirect recursion through callback chains — is out.
Pre-start self-test (§9.4.3). Every ASIL-B safety module must run a self-test during initialization, before serving any requests. For a UDS server this means verifying the safety infrastructure itself before accepting any session.
Safety violation accounting. Every rejected request due to a safety check failure — wrong session, insufficient security level, out-of-bounds length — must be counted and preserved. You cannot reset these counters in production. They are evidence.
Controlled DID access. Every DID read and write must pass through a validated access chain: existence check, session gate, security level gate, permission gate, length gate — in that order, every time. You cannot shortcut it. You cannot let a service handler call the DID database directly.
That last requirement is the one that causes the most architectural headaches. It means every service handler (0x22 ReadDataByIdentifier, 0x2E WriteDataByIdentifier, etc.) needs a generated safety wrapper around every DID — not a runtime check, a compile-time-verified, statically-allocated wrapper.
The five-step access chain
A runtime check can be skipped by a developer who misunderstands the architecture. A structural constraint that removes the unsafe path entirely cannot be.
In EDS, every DID access — read or write — routes through five checkpoints in core/uds_safety.c:
Step 1: DID exists in database uds_safety_find_did() → NRC 0x31 Step 2: Current session is allowed uds_safety_validate_did_access() → NRC 0x7F Step 3: Security level is sufficient uds_safety_validate_did_access() → NRC 0x33 Step 4: Access permission is correct uds_safety_validate_did_access() → NRC 0x31 Step 5: Data length is valid uds_safety_check_did_data_length() → NRC 0x13
Service handlers in core/uds_services/ never call did_database_find() directly. This is enforced structurally — the DID database lookup function isn't exported from the safety layer. Every DID access goes through the safety module. This is requirement REQ-SAFE-001 through REQ-SAFE-007 in core/uds_safety.h, traceable to the RTM.
The implementation is static. Every table entry is a compile-time constant. The safety module itself has no dynamic allocation. Total stack depth for the worst-case service call (a multi-frame write with SecurityAccess) is analyzable to a fixed number.
The code generation approach
The access chain is not enough on its own. You also need the per-DID safety wrappers — the C functions that implement the five steps for each specific DID in your configuration.
Writing those by hand is where teams fall down. You have 20 DIDs. Some are read-only in default session, some need SecurityAccess level 1 for write, some are extended-session only. Writing 20 × 5 = 100 safety checks correctly, maintaining them when the config changes, and keeping the RTM synchronized is a full-time job.
EDS solves this with codegen.py. You describe your ECU's diagnostic interface in YAML:
dids:
- id: "0xF190"
name: "VIN"
data_length: 17
access: [read]
min_session: default
read_security_level: 0
write_security_level: 0
- id: "0xF187"
name: "SparePart"
data_length: 11
access: [read, write]
min_session: extended
read_security_level: 0
write_security_level: 1
The generator produces did_safety_wrappers.c and did_safety_wrappers.h — one wrapper per DID, each implementing the full five-step chain with the correct session/security/length constants for that specific DID.
Critically, the generator enforces the ASIL-B constraint at generation time:
SAFETY [HIGH-1]: dids[1] (id='0xF187') 'SparePart' has write access but write_security_level=0. FIX: set write_security_level >= 1 in diagnostics_config.yaml
This is the key property: the misconfiguration is caught at generation time, not at runtime, not in a code review. A write-capable DID with no security requirement is a generator error, not a runtime warning. The build fails. There is no path from a misconfigured YAML to a shipped binary that bypasses security on a write DID.
Static allocation in practice
The DID and DTC databases are statically allocated arrays sized by the generator from the YAML:
/* generated/generated_config.h — do not edit */ #define UDS_DID_TABLE_SIZE 5U #define UDS_DTC_TABLE_SIZE 2U #define UDS_ROUTINE_TABLE_SIZE 2U
These constants feed the database init functions. No runtime allocation. No linked lists. Stack depth for uds_server_handle_request() is bounded by the longest service call path, which the Zephyr thread config can set with a fixed stack size.
The NVM mirror for DTCs — storing which DTCs have fired, which are confirmed, which are pending — is a static ring buffer. Size is a compile-time constant derived from UDS_DTC_TABLE_SIZE. No heap.
The pre-start self-test
uds_safety_self_test() is called at Step 1.1 in the generated init sequence, after uds_safety_init() and before any database initialization. It tests the safety module's own null pointer checks, bounds checks, and violation counter saturation — using known-bad inputs and verifying that the correct errors are returned.
/* generated/uds_init.c — do not edit */
status = uds_safety_init();
if (status != UDS_STATUS_OK) { return status; }
/* Step 1.1 — ISO 26262-6 §9.4.3 pre-start self-test */
{
uds_safety_result_t rc = uds_safety_self_test();
if (rc != UDS_STATUS_OK) { return rc; }
}
If the self-test fails — which it should only do if memory is corrupted before init or the toolchain itself has a serious bug — the ECU enters a safe state before accepting any diagnostic session.
This step is generated, not handwritten. If it's removed from the template, the generator's own test suite fails. The CI gate that checks for uds_safety_self_test() in the generated output catches any regression.
Running this on Zephyr native_sim
The complete ASIL-B stack — safety module, session FSM, security access, DID dispatch, DTC persistence, ISO-TP framing — runs unmodified on Zephyr's native_sim target. CI builds and runs the basic_ecu example on every push:
west build -b native_sim examples/basic_ecu west build -t run
The native_sim target uses a CAN loopback socket. The same binary, the same UDS handlers, the same ASIL-B wrappers that run in CI are what you flash to your STM32 or Nordic SoC on your bench. Not a simulator mode. Not reduced functionality. The platform abstraction layer is four callbacks — can_send, can_recv, nvm_read, nvm_write — and Zephyr provides the native_sim implementations automatically.
The complete stack is on GitHub — clone it and run the basic_ecu example in under 15 minutes: github.com/Xaloqi/EDS
What this is not
This is not a claim that EDS alone makes your product ASIL-B. ASIL-B is a system-level requirement. It covers hardware fault tolerance, FMEA, HARA, verification activities, management processes. EDS handles the software architecture for the diagnostics subsystem. The Professional tier ships a Safety Manual, HARA extract, Requirements Traceability Matrix, and MISRA C:2012 deviation log to give your audit the substrate it needs.
What EDS does give you is a diagnostics stack where the ASIL-B software properties are not assertions — they are enforced by the build system, verified by the generator's own tests, and traceable to named requirements. Starting from that baseline is a different engineering project than starting from scratch or from a library that wasn't designed for it.
The generator test that proved it
The most useful single test we have is this one, in the harness suite:
/* Group C — ReadDataByIdentifier */
TEST("0x22 in wrong session returns NRC 0x7F") {
/* Set session to default. Attempt to read a DID that requires extended. */
result = uds_tester_send_read_did(&ctx, 0xF187);
ASSERT_NRC(result, 0x7F); /* requestOutOfRange — session gate */
}
It doesn't test that the session gate might work. It tests that the session gate cannot be bypassed — that the five-step chain is always in the path, for this specific DID, on this specific service call, under these specific conditions. That test runs in CI on every commit. If any change to the service handler, the safety wrapper, or the DID table breaks the gate, the test fails before the branch merges.
That's what "ASIL-B-ready" means in practice for a diagnostics stack. Not a certification. A property that the build system enforces and the test suite verifies — every time, automatically.
native_sim today. The Developer and Professional tiers add the codegen templates, AI tooling, and safety documentation package your audit will need. See pricing →