UDS Diagnostics Intro (ISO 14229)¶
When a workshop plugs a tester into the OBD connector and reads a fault code, when an end-of-line station writes a VIN into a fresh ECU, and when the bootloader from module 8 is asked to accept a new image — all three are the same protocol. UDS, Unified Diagnostic Services, is the request/response language every automotive ECU speaks, standardised as ISO 14229 and carried over CAN by ISO 15765-2 (ISO-TP). An ECU without a UDS server cannot be diagnosed, configured or reflashed, and no OEM will accept it.
The request/response shape¶
Request SID [sub-function] [data ...]
Positive resp SID + 0x40 [data ...]
Negative resp 0x7F SID NRC
A request for the active session (22 F1 86) is answered 62 F1 86 03; a
rejected one is answered 7F 22 31 — service 0x22, NRC 0x31
requestOutOfRange. Three rules trip up first implementations. The
response SID is the request SID plus 0x40 — not a separate identifier.
A sub-function's bit 7 is suppressPosRspMsgIndicationBit: if the
tester sets it, send no positive response, but still send a negative one
if the request fails. And silence is never a valid answer — any
request you understand enough to address must produce a response or a
documented NRC, because a tester timeout is reported as "ECU not
responding", which reads like a hardware fault.
| NRC | Name | When |
|---|---|---|
0x11 |
serviceNotSupported | SID not implemented |
0x12 |
subFunctionNotSupported | SID exists, sub-function does not |
0x13 |
incorrectMessageLengthOrInvalidFormat | Length check failed |
0x22 |
conditionsNotCorrect | Vehicle moving, voltage too low |
0x24 |
requestSequenceError | e.g. key before seed |
0x31 |
requestOutOfRange | Unknown DID, address outside range |
0x33 |
securityAccessDenied | Service needs unlocking first |
0x35 / 0x36 / 0x37 |
invalidKey / exceedNumberOfAttempts / requiredTimeDelayNotExpired | SecurityAccess failures |
0x78 |
requestCorrectlyReceivedResponsePending | Working on it; restarts P2 |
0x7E / 0x7F |
subFunction- / serviceNotSupportedInActiveSession | Wrong session |
Sessions and the S3 timer¶
The ECU is always in exactly one diagnostic session, and the session decides which services are legal.
| ID | Session | Typical rights |
|---|---|---|
0x01 |
default | Read-only: 0x22 DIDs, 0x19 read DTCs, 0x3E |
0x02 |
programming | Download services 0x34/0x36/0x37 — usually the bootloader |
0x03 |
extendedDiagnostic | Writes (0x2E), routines (0x31), actuator tests (0x2F) |
0x04 |
safetySystemDiagnostic | Safety routines behind security access |
Three timers govern the conversation. P2 — the ECU answers within
typically 50 ms. P2* — the extended budget, typically 5000 ms,
granted only after replying 0x78 responsePending. S3 — after
5000 ms with no diagnostic request the ECU falls back to the default
session by itself; 0x3E TesterPresent exists purely to reset it. That
fallback is a safety feature: if a technician unplugs the tester
mid-actuator-test, the ECU must return to default, which means returning
actuators to normal control.
#define UDS_S3_MS 5000u
#define UDS_P2_MS 50u
typedef enum {
UDS_SESSION_DEFAULT = 0x01u,
UDS_SESSION_PROGRAM = 0x02u,
UDS_SESSION_EXTENDED = 0x03u,
} uds_session_t;
typedef struct {
uds_session_t session;
uint32_t s3_ms;
bool unlocked;
uint8_t key_attempts;
uint32_t seed;
} uds_ctx_t;
static uds_ctx_t g_uds = { .session = UDS_SESSION_DEFAULT };
void uds_task_10ms(void)
{
if (g_uds.session == UDS_SESSION_DEFAULT) { return; }
if (g_uds.s3_ms > 10u) {
g_uds.s3_ms -= 10u;
} else {
g_uds.session = UDS_SESSION_DEFAULT;
g_uds.unlocked = false; /* security drops with session */
app_stop_all_actuator_tests(); /* back to normal control */
}
}
ISO-TP: fitting 4 KB into 8-byte frames¶
A CAN frame carries 8 bytes; a 0x22 response with a VIN needs 20.
ISO 15765-2 segments the message using the first byte or two of each
frame:
| PCI | Name | Layout |
|---|---|---|
0x0L |
Single Frame | 0L plus up to 7 data bytes (L = length) |
0x1LLL |
First Frame | 1 plus 12-bit length, then 6 data bytes |
0x2S |
Consecutive Frame | 2 plus 4-bit sequence number, wrapping 0–15 |
0x3F BS ST |
Flow Control | FlowStatus, BlockSize, SeparationTime |
The flow is fixed: First Frame, then one Flow Control from the receiver,
then Consecutive Frames numbered 1, 2, … F, 0, 1, …. In your Flow
Control, FlowStatus is 0 ContinueToSend, 1 Wait, 2 Overflow —
use 2 when the declared length exceeds your buffer rather than
truncating silently. BlockSize 0 means send everything without
pausing. STmin is 0x00–0x7F milliseconds or 0xF1–0xF9 for
100–900 µs; an ECU with a slow receive path asks for a larger STmin
instead of dropping frames. That is your back-pressure control.
Addresses on a typical 11-bit bus are 0x7DF functional (tester → every
ECU), 0x7E0 physical request and 0x7E8 physical response for ECU #1.
Functional addressing is why length checks matter: every ECU on the bus
sees 0x7DF, so a malformed frame must be rejected by all of them without
side effects.
A minimal server on S32K¶
The dispatcher sits on the FlexCAN plumbing from module 1 — 0x7E0 and
0x7DF are two more filter entries, and responses go out through a
dedicated TX mailbox so they never contend with periodic application
frames.
#define SID_SESSION_CONTROL 0x10u
#define SID_READ_DID 0x22u
#define SID_SECURITY_ACCESS 0x27u
#define SID_WRITE_DID 0x2Eu
#define SID_ROUTINE_CONTROL 0x31u
#define SID_TESTER_PRESENT 0x3Eu
static uint8_t g_resp[UDS_BUF_SIZE];
static uint16_t g_respLen;
static void uds_negative(uint8_t sid, uint8_t nrc)
{
g_resp[0] = 0x7Fu; g_resp[1] = sid; g_resp[2] = nrc;
g_respLen = 3u;
}
void uds_handle_request(const uint8_t *req, uint16_t len, bool functional)
{
g_respLen = 0u;
if (len == 0u) { return; }
g_uds.s3_ms = UDS_S3_MS; /* any request resets S3 */
switch (req[0]) {
case SID_SESSION_CONTROL: uds_session_control(req, len); break;
case SID_READ_DID: uds_read_did(req, len); break;
case SID_WRITE_DID: uds_write_did(req, len); break;
case SID_SECURITY_ACCESS: uds_security_access(req, len); break;
case SID_ROUTINE_CONTROL: uds_routine_control(req, len); break;
case SID_TESTER_PRESENT:
if (len != 2u) { uds_negative(req[0], 0x13u); break; }
g_resp[0] = 0x7Eu; g_resp[1] = req[1] & 0x7Fu; g_respLen = 2u;
break;
default:
uds_negative(req[0], 0x11u);
break;
}
/* suppressPosRspMsgIndicationBit */
if ((len >= 2u) && ((req[1] & 0x80u) != 0u) && (g_resp[0] != 0x7Fu)) {
g_respLen = 0u;
}
/* Never answer a functional request with serviceNotSupported — the
bus would fill with identical negatives from every ECU. */
if (functional && (g_resp[0] == 0x7Fu) &&
((g_resp[2] == 0x11u) || (g_resp[2] == 0x7Fu))) {
g_respLen = 0u;
}
if (g_respLen != 0u) { isotp_send(0x7E8u, g_resp, g_respLen); }
}
0x22 ReadDataByIdentifier is the service you implement first and use
forever. DIDs are 16-bit, and the standard reserves a block of them:
#define DID_ACTIVE_SESSION 0xF186u
#define DID_SW_VERSION 0xF189u
#define DID_VIN 0xF190u
#define DID_COOLANT_TEMP 0x0100u /* our own, from the capstone */
static void uds_read_did(const uint8_t *req, uint16_t len)
{
if (len != 3u) { uds_negative(SID_READ_DID, 0x13u); return; }
uint16_t did = ((uint16_t)req[1] << 8) | req[2];
g_resp[0] = SID_READ_DID + 0x40u;
g_resp[1] = req[1];
g_resp[2] = req[2];
switch (did) {
case DID_ACTIVE_SESSION:
g_resp[3] = (uint8_t)g_uds.session;
g_respLen = 4u;
break;
case DID_VIN:
(void)nvm_read(NVM_ID_VIN, &g_resp[3], 17u); /* module 6 */
g_respLen = 20u; /* multi-frame */
break;
case DID_COOLANT_TEMP: {
int16_t t = app_get_temp_dC(); /* Level 1 node */
g_resp[3] = (uint8_t)((uint16_t)t >> 8);
g_resp[4] = (uint8_t)((uint16_t)t & 0xFFu);
g_respLen = 5u;
break;
}
default:
uds_negative(SID_READ_DID, 0x31u);
break;
}
}
SecurityAccess and RoutineControl¶
0x27 is a two-step handshake: an odd sub-function requests a seed, the
next even sub-function delivers the key. The algorithm is OEM-specific and
confidential; what is standardised is the shape and the lockout.
static void uds_security_access(const uint8_t *req, uint16_t len)
{
if (len < 2u) { uds_negative(SID_SECURITY_ACCESS, 0x13u); return; }
if (g_uds.session == UDS_SESSION_DEFAULT) {
uds_negative(SID_SECURITY_ACCESS, 0x7Fu); /* wrong session */
return;
}
if ((req[1] & 1u) != 0u) { /* requestSeed */
g_uds.seed = g_uds.unlocked ? 0u : sec_generate_seed();
g_resp[0] = SID_SECURITY_ACCESS + 0x40u;
g_resp[1] = req[1];
g_resp[2] = (uint8_t)(g_uds.seed >> 24);
g_resp[3] = (uint8_t)(g_uds.seed >> 16);
g_resp[4] = (uint8_t)(g_uds.seed >> 8);
g_resp[5] = (uint8_t)(g_uds.seed);
g_respLen = 6u;
} else { /* sendKey */
if (len != 6u) { uds_negative(SID_SECURITY_ACCESS, 0x13u); return; }
if (g_uds.seed == 0u) {
uds_negative(SID_SECURITY_ACCESS, 0x24u); /* key w/o seed */
return;
}
uint32_t key = ((uint32_t)req[2] << 24) | ((uint32_t)req[3] << 16)
| ((uint32_t)req[4] << 8) | (uint32_t)req[5];
uint32_t seed = g_uds.seed;
g_uds.seed = 0u; /* one shot only */
if (key == sec_expected_key(seed)) {
g_uds.unlocked = true; g_uds.key_attempts = 0u;
g_resp[0] = SID_SECURITY_ACCESS + 0x40u;
g_resp[1] = req[1]; g_respLen = 2u;
} else {
g_uds.key_attempts++;
uds_negative(SID_SECURITY_ACCESS,
(g_uds.key_attempts >= 3u) ? 0x36u : 0x35u);
if (g_uds.key_attempts >= 3u) { sec_start_lockout(10000u); }
}
}
}
0x31 RoutineControl is how the bootloader gets invoked: sub-function
0x01 startRoutine with a routine identifier such as 0xFF00
eraseMemory. The reprogramming path itself is 10 02 — the application
enters the programming session, writes module 8's .noinit magic and
issues a software reset, which is exactly the guarded path the bootloader
refused to boot without.
Automotive concerns¶
- Answer within P2 or say
0x78. A flash erase takes hundreds of milliseconds. Send0x78immediately, run the long operation in a task, and send the real response when it finishes. Going quiet for 400 ms is indistinguishable, to a tester, from a dead ECU. - Session state is safety state. Extended and programming sessions drive actuators and stop normal control loops. The S3 timeout, a session change and any reset must all return outputs to the safe state through one function, not three copies of the logic.
- Guard with preconditions, then say
0x22. Vehicle speed above zero, engine running, battery below threshold: each is a reason to reject with conditionsNotCorrect. One precondition function per service keeps the rules reviewable. - Security access must survive a power-cycle attack. The lockout timer and attempt counter belong in FlexNVM (module 6), not only in RAM — otherwise three attempts plus a reset gives unlimited tries. Keep the write frequency low so the counter does not become a flash-wear problem.
- ISO-TP buffers are an attack surface. A First Frame can claim 4095 bytes. Check the declared length against your buffer before allocating and answer FlowStatus Overflow; never index a Consecutive Frame payload without re-checking the running offset.
- Diagnostics share the bus with control traffic. A 4 KB response at
BlockSize 0 saturates a 500 kbit/s bus and delays safety-relevant
frames. Keep diagnostic IDs low-priority (
0x7E8already is), use a dedicated TX mailbox, and set STmin so the transfer paces itself. - DTC services write to flash.
0x14ClearDiagnosticInformation and0x19ReadDTCInformation sit directly on module 6's fault memory. Debounce and coalesce before persisting; a rapidly toggling fault must not become thousands of flash writes.
Cheat sheet¶
| Item | Notes |
|---|---|
| Positive response | request SID + 0x40; negative is 7F SID NRC |
| Suppress bit | Sub-function bit 7 — no positive response, negatives still sent |
| Sessions | 0x01 default · 0x02 programming · 0x03 extended · 0x04 safety |
| Timers | P2 ≈ 50 ms · P2* ≈ 5000 ms after 0x78 · S3 = 5000 ms back to default |
| TesterPresent | 3E 00 → 7E 00; 3E 80 suppresses the response |
| Core services | 0x10 session · 0x11 reset · 0x14/0x19 DTCs · 0x22/0x2E DIDs · 0x27 security · 0x31 routine · 0x34/0x36/0x37 download |
| Common DIDs | 0xF186 active session · 0xF189 SW version · 0xF190 VIN · 0xF18C serial |
| Key NRCs | 0x13 length · 0x22 conditions · 0x31 range · 0x33 locked · 0x35 bad key · 0x78 pending |
| ISO-TP PCI | 0x0L SF · 0x1LLL FF · 0x2S CF · 0x3F BS ST FC |
| FlowStatus | 0 continue · 1 wait · 2 overflow (message exceeds your buffer) |
| STmin | 0x00–0x7F ms · 0xF1–0xF9 = 100–900 µs |
| CAN IDs | 0x7DF functional · 0x7E0 physical request · 0x7E8 response |
| S32K plumbing | Extra FlexCAN filter entries for 0x7E0/0x7DF, dedicated TX mailbox for 0x7E8 |
How It Actually Works¶
UDS (ISO 14229) runs on top of CAN's physical arbitration and framing described earlier, but its "actually works" layer that matters here is the underlying transport protocol, ISO-TP (ISO 15765-2), which exists purely to work around CAN's fixed 8-byte (or 64-byte for CAN FD) payload limit at the silicon level. A UDS request larger than one frame gets split by ISO-TP into a First Frame (carrying a length field and the first chunk of data) and Consecutive Frames — and the receiver controls flow using a Flow Control frame that specifies block size and a minimum separation time (STmin), which the sender's hardware/software must physically respect between transmitted frames; this exists because a receiving ECU's FlexCAN RX message buffers and its CPU's ability to drain them via interrupt or DMA are finite, and STmin prevents the sender from overrunning that receive pipeline.
Security Access (UDS service 0x27) is a real challenge-response cryptographic exchange, not a password check: the ECU's seed generator (frequently backed by a hardware security module or S32K's CSEc/HSE crypto accelerator on higher-end parts) produces a seed, the tester computes a key using a shared algorithm/key material, and the ECU's own hardware crypto engine recomputes the expected key internally to compare — critically, the correct key material never needs to leave the ECU's secure enclave for the comparison to happen, which is what prevents key extraction via bus sniffing.
Diagnostic Session Control and ECU Reset services interact directly with the silicon reset controller and SMC power-mode hardware discussed earlier — a UDS "hard reset" request ultimately asserts the same physical reset line that NRST or the watchdog would, which is why a successful UDS reset boots through the exact same vector-fetch sequence as a power-on reset, vector table and all.
(Described from ISO 14229/ISO 15765-2 and the S32K reference manual's FlexCAN/security chapters; not measured on physical silicon in this course.)
Exercise¶
Add a UDS server to your Level 1 capstone node and prove it against a real
tester. (1) Implement ISO-TP on its own first: Single Frame both ways,
then First/Consecutive/Flow Control with configurable BlockSize and STmin.
Test it with a 40-byte message before any UDS code exists — a transport
bug found later will look like a UDS bug and cost you a day.
(2) Implement 0x3E, 0x10 and 0x22 with the DIDs above, including
0xF190 VIN so you exercise multi-frame responses; verify with
cansend/candump or a Python udsoncan script that 03 22 F1 90
produces a First Frame, your Flow Control, and the rest. (3) Implement the
S3 timer and demonstrate it — enter the extended session, stop sending
TesterPresent, and confirm via 0xF186 that the ECU is back in default
exactly 5 s later. (4) Implement 0x2E writing the VIN to FlexNVM, gated
behind 0x27 with a deliberately trivial key algorithm; confirm 0x33
when locked and 0x36 after three bad keys, then power-cycle and confirm
the lockout survived. (5) Add 0x78 around a 0x31 routine that
deliberately takes 500 ms and check with a timestamped candump that the
pending frames arrive inside P2. Without hardware, build the whole server
against a SocketCAN vcan0 interface — every step runs unchanged there,
and the ISO-TP state machine is the part worth getting right.