Skip to content

OBD-II K-line Protocol

OBD-II over K-line (ISO 9141-2 and ISO 14230 / KWP2000) is a standard vehicle diagnostic protocol. Unlike BMW I/K-Bus which is multi-master and always-on, K-line is master/slave — the tester (ESP32) initiates all communication, and the ECU only responds when asked.

The K-Line library implements K-line support through KLineObd2, which handles initialization, request/response framing, echo clearing, checksum validation, and session keepalive.

ParameterValue
Baud rate10400
Framing8N1 (8 data bits, no parity, 1 stop bit)
ChecksumAdditive sum mod 256
Bus topologyMaster/slave (tester initiates)
Bus typeSingle-wire, half-duplex
Idle stateHIGH
Session timeout (P3max)~5 seconds

K-line ECUs require an initialization sequence before they accept diagnostic requests. Two methods are supported — the ECU determines which one it responds to.

The older, more widely supported method. Sends the target ECU address at 5 baud (200ms per bit), then negotiates baud rate and protocol capabilities.

Full sequence:

  1. Hold TX HIGH for 300ms (W0 idle period)
  2. Bit-bang target address (default 0x33) at 5 baud: start bit + 8 data bits LSB-first + stop bit. Total: 2 seconds for one byte
  3. Re-attach UART at 10400 baud
  4. Flush RX buffer (UART received garbage during the 2-second bit-bang)
  5. Read sync byte (0x55) from ECU — confirms baud rate agreement (W1: 20-300ms timeout)
  6. Read keyword byte 1 (KW1) from ECU (W2: 5-20ms between bytes)
  7. Read keyword byte 2 (KW2) from ECU
  8. Wait 25-50ms (W3)
  9. Send inverted KW2 (~KW2) back to ECU — tester confirms handshake
  10. Verify echo of inverted KW2 (half-duplex bus reflection)
  11. Read inverted target address from ECU — final acknowledgment

The target address 0x33 is the ISO 9141 “all ECUs” broadcast. The keyword bytes declare the ECU’s protocol capabilities.

Implementation detail: The ESP32 UART peripheral cannot generate 5 baud. The library detaches the UART from the TX pin using pinMatrixOutDetach(), bit-bangs using digitalWrite() with 200ms delays, then re-attaches the UART for normal communication.

The faster method. Sends a timed pulse followed by a StartCommunication service request.

Full sequence:

  1. Hold TX HIGH for 300ms (idle)
  2. TiniPulse: TX LOW for 25ms, then TX HIGH for 25ms
  3. Re-attach UART, flush RX
  4. Send StartCommunication request: [0xC1, 0x33, 0xF1, 0x81] + checksum
    • 0xC1: format byte (functional addressing, 1 data byte)
    • 0x33: target (functional address)
    • 0xF1: source (tester)
    • 0x81: StartCommunication service ID
  5. Verify echo (5 bytes on half-duplex bus)
  6. Read positive response (0xC1 = service ID 0x81 + 0x40)
  7. Extract keyword bytes from response

The scanner sketch tries slow init first, then falls back to fast init if slow init fails.

K-line is a single wire. Everything the tester transmits appears on the RX line as well. After every transmission, the library reads back and verifies each echo byte against what was sent.

bool clearEcho(const uint8_t* expected, uint8_t count);

A mismatch indicates bus contention — another device was transmitting at the same time. Init sequences abort on echo mismatch rather than trying to parse a corrupted response.

Both requests and responses use the ISO 14230 frame structure:

[Format] [Target] [Source] [Data...] [Checksum]
or
[Format] [Target] [Source] [Length] [Data...] [Checksum]

The first byte encodes the addressing mode and data length:

BitsFieldValues
7:6Address mode0=none, 1=CARB (unsupported), 2=physical, 3=functional
5:0Data length0=separate length byte follows, 1-63=inline length

When address mode is 2 or 3, two address bytes follow (target + source). When the data length field is 0, a separate length byte appears after the address bytes.

The library’s parseFrameData() decodes this structure and returns the offset of the first data byte (service ID), regardless of which header format the ECU uses.

Additive sum modulo 256 of all bytes except the checksum itself:

checksum = (byte[0] + byte[1] + ... + byte[N-1]) & 0xFF

The library validates the checksum on every received frame. Frames with invalid checksums are rejected.

uint8_t requestPid(uint8_t mode, uint8_t pid,
uint8_t* response, uint8_t maxLen);

The library constructs an ISO 14230 frame with physical addressing:

[0x82] [ECU_ADDR] [TESTER_ADDR] [MODE] [PID] [CHECKSUM]
  • 0x82: physical addressing, 2 data bytes
  • ECU address: from init handshake
  • Tester address: 0xF1

The request is transmitted, echo bytes are cleared and verified, and the response is read with a 2-second timeout.

A positive response has the service ID = request mode + 0x40. For a Mode 01 request:

Request: mode=0x01, pid=0x0C (RPM)
Response: service_id=0x41, pid=0x0C, data_A, data_B

The library verifies the response service ID and PID match before returning data bytes.

If the ECU returns service 0x7F, it’s a negative response. The third data byte is the Negative Response Code (NRC):

NRCMeaning
0x10General reject
0x11Service not supported
0x12Sub-function not supported
0x22Conditions not correct
0x78Response pending (ECU needs more time)

requestPid() returns 0 on negative response. In debug mode (-DKLINE_DEBUG), the NRC code is printed to serial.

These are Mode 01 (current data) PIDs from SAE J1979. The Obd2Pids.h header provides constants and inline decode functions for each.

PIDHexDescriptionFormulaUnitRange
Engine Load0x04Calculated engine loadA * 100 / 255%0-100
Coolant Temp0x05Engine coolant temperatureA - 40C-40 to 215
Short Fuel Trim0x06Short term fuel trim, bank 1A / 1.28 - 100%-100 to 99.2
Long Fuel Trim0x07Long term fuel trim, bank 1A / 1.28 - 100%-100 to 99.2
Intake Pressure0x0BIntake manifold pressureAkPa0-255
Speed0x0DVehicle speedAkm/h0-255
Timing Advance0x0ETiming advanceA / 2 - 64deg BTDC-64 to 63.5
Intake Temp0x0FIntake air temperatureA - 40C-40 to 215
Throttle0x11Throttle positionA * 100 / 255%0-100
Baro Pressure0x33Barometric pressureAkPa0-255
Fuel Level0x2FFuel tank levelA * 100 / 255%0-100
Oil Temp0x5CEngine oil temperatureA - 40C-40 to 210
PIDHexDescriptionFormulaUnitRange
RPM0x0CEngine RPM(A * 256 + B) / 4RPM0-16383.75
MAF Rate0x10MAF air flow rate(A * 256 + B) / 100g/s0-655.35
Run Time0x1FTime since engine startA * 256 + Bs0-65535
Module Voltage0x42Control module voltage(A * 256 + B) / 1000V0-65.535
Fuel Rate0x5EEngine fuel rate(A * 256 + B) / 20L/h0-3212.75

PID 0x00 returns a 4-byte bitmask indicating which PIDs 0x01 through 0x20 the ECU supports. PID 0x20 returns the bitmask for 0x21-0x40, and PID 0x40 for 0x41-0x60. Query these first to avoid requesting unsupported PIDs.

ModeHexDescription
Current Data0x01Read current sensor values (most common)
Freeze Frame0x02Read data snapshot from last DTC
Stored DTCs0x03Read stored diagnostic trouble codes
Clear DTCs0x04Clear stored DTCs and freeze frame
O2 Monitoring0x05O2 sensor monitoring test results
Test Results0x06On-board monitoring test results
Pending DTCs0x07Read pending DTCs (current drive cycle)
Control0x08Control of on-board systems
Vehicle Info0x09VIN, calibration IDs, etc.
Permanent DTCs0x0APermanent DTCs (cannot be cleared)

The current K-Line implementation handles Mode 01 (current data) PID requests. Modes 03 and 04 (DTC read/clear) are planned.

K-line diagnostic sessions expire after the P3 timeout (typically 5 seconds of inactivity). The library sends TesterPresent (service 0x3E) automatically when more than 4 seconds have elapsed since the last request. This is transparent to the caller — requestPid() checks the timer internally.

// P3_KEEPALIVE_MS = 4000
if (millis() - _lastRequestMs > P3_KEEPALIVE_MS) {
sendTesterPresent();
}

If the session is lost (ECU stops responding), call reset() to clear the initialized state, then re-run slowInit() or fastInit(). The scanner sketch tracks consecutive failures and re-initializes automatically after 5 total failures:

if (consecutiveFailures >= MAX_FAILURES_BEFORE_REINIT) {
scanner->reset();
tryInit(); // slow init, then fast init fallback
}
ConditionBehavior
Echo mismatchclearEcho() returns false (bus contention detected)
Checksum invalidreadResponse() returns 0, frame discarded
TimeoutreadByteTimeout() returns -1 after deadline; uses remainingMs() to prevent unsigned underflow
Negative responserequestPid() returns 0, NRC logged in debug mode
Buffer overflowResponse buffer is 32 bytes; messages exceeding this are truncated (checksum cannot be validated)
Session expiredAfter 5 consecutive all-zero poll cycles, scanner calls reset() and re-initializes

Defined in Obd2Pids.h:

namespace obd2 {
constexpr uint8_t SYNC_BYTE = 0x55; // ECU sync response
constexpr uint8_t DEFAULT_TARGET = 0x33; // ISO 9141 default ECU address
constexpr uint8_t TESTER_ADDR = 0xF1; // external test equipment
constexpr uint8_t FUNC_ADDR = 0x33; // functional addressing
constexpr uint8_t KWP_FMT_NO_ADDR = 0x00; // no address info
constexpr uint8_t KWP_FMT_PHYS_ADDR = 0x80; // physical addressing
constexpr uint8_t KWP_FMT_FUNC_ADDR = 0xC0; // functional addressing
}