Why J1939 feels different from normal CAN
J1939 runs on CAN, but it changes how you should think about identifiers and signals.
In a typical proprietary CAN database, one CAN ID often maps directly to one message definition in the DBC. In J1939, the important identifier is usually the PGN, or Parameter Group Number. The PGN is derived from the 29-bit extended CAN identifier. The individual parameters inside a PGN are called SPNs, or Suspect Parameter Numbers.
That gives you a three-layer mental model:
29-bit CAN ID -> PGN -> SPNs
The CAN ID still matters. It contains priority, addressing fields, and source address. But for decoding engineering values, the PGN is the key.
The 29-bit J1939 identifier
A J1939 CAN ID uses the extended 29-bit format.
The fields are commonly viewed like this:
Priority | Reserved | Data Page | PDU Format | PDU Specific | Source Address
3 bits | 1 bit | 1 bit | 8 bits | 8 bits | 8 bits
The PGN is built from:
Reserved + Data Page + PDU Format + PDU Specific
There is one important rule:
- If PDU Format is below
240, the PDU Specific field is a destination address, so the PGN's low byte is treated as00. - If PDU Format is
240or above, the PDU Specific field is part of the PGN.
This is the PDU1 vs PDU2 distinction that causes a lot of first-time J1939 confusion.
Extract PGN fields in Python
This function is a useful sanity check when looking at candump logs.
def decode_j1939_id(can_id: int) -> dict[str, int]:
priority = (can_id >> 26) & 0x7
reserved = (can_id >> 25) & 0x1
data_page = (can_id >> 24) & 0x1
pdu_format = (can_id >> 16) & 0xFF
pdu_specific = (can_id >> 8) & 0xFF
source_address = can_id & 0xFF
if pdu_format < 240:
pgn = (reserved << 17) | (data_page << 16) | (pdu_format << 8)
destination_address = pdu_specific
else:
pgn = (reserved << 17) | (data_page << 16) | (pdu_format << 8) | pdu_specific
destination_address = None
return {
"priority": priority,
"reserved": reserved,
"data_page": data_page,
"pdu_format": pdu_format,
"pdu_specific": pdu_specific,
"source_address": source_address,
"destination_address": destination_address,
"pgn": pgn,
}
fields = decode_j1939_id(0x0CF00401)
print(fields)
print(hex(fields["pgn"]), fields["pgn"])
Expected PGN:
0xf004 61444
PGN 61444 is commonly known as EEC1, Electronic Engine Controller 1, in J1939 examples.
Decode a candump log into PGN counts
Before loading a J1939 DBC, it helps to know which PGNs are present.
import re
from collections import Counter
from pathlib import Path
line_re = re.compile(r"\([^)]+\)\s+\w+\s+(?P<id>[0-9A-Fa-f]+)#(?P<data>[0-9A-Fa-f]*)")
def pgn_from_can_id(can_id: int) -> int:
pdu_format = (can_id >> 16) & 0xFF
pgn = (can_id >> 8) & 0x3FFFF
if pdu_format < 240:
pgn &= 0x3FF00
return pgn
counts = Counter()
for line in Path("j1939-capture.log").read_text().splitlines():
match = line_re.match(line)
if not match:
continue
can_id = int(match.group("id"), 16)
counts[pgn_from_can_id(can_id)] += 1
for pgn, count in counts.most_common(20):
print(f"PGN {pgn:06d} 0x{pgn:05X}: {count} frames")
This gives you the traffic profile before you decode values.
What an SPN really is
An SPN is a signal definition inside a PGN. It describes how to interpret part of the data payload.
An SPN definition needs the same practical fields you already know from DBC work:
- start position
- bit length
- byte order
- scale
- offset
- unit
- valid range or special values
Example decoding logic:
Payload bytes: FF FF FF 68 13 FF FF FF
Engine speed raw bytes: 68 13
Little-endian raw value: 0x1368 = 4968
Scale: 0.125 rpm/bit
Decoded value: 621 rpm
This is why a J1939 DBC is valuable. It packages PGN/SPN definitions into a format tools can apply consistently.
Use candump with J1939
Capture all J1939 traffic on a SocketCAN interface:
candump -t a can0
Log it:
candump -t a -l can0
Filter a PGN class manually by CAN ID mask when needed. For example, PGN 0xF004 appears in IDs where the PF/PS bytes are F0 04, while the source address can vary.
candump can0,0CF00400:03FFFF00
Do not treat this as a universal J1939 filter recipe. It depends on whether you are matching priority, reserved/data page bits, PGN bits, and source address. For quick analysis, parsing a full log with Python is often less error-prone.
Decode with a J1939 DBC
With a J1939 DBC:
cat j1939-capture.log | python3 -m cantools decode j1939.dbc
If you only want one-line output:
cat j1939-capture.log | python3 -m cantools decode --single-line j1939.dbc
If the decoder reports unknown frames, ask:
- Is the DBC using the expected PGN representation?
- Is the frame extended, not standard?
- Is this a proprietary PGN?
- Is the source address changing?
- Is the capture from the bus segment the DBC was built for?
PDU1 vs PDU2 in practical terms
PDU1 messages are destination-specific.
PF < 240
PS = destination address
PGN low byte = 00
PDU2 messages are broadcast-style group extension messages.
PF >= 240
PS = group extension
PGN includes PS
If your PGN extraction script ignores this rule, you will group frames incorrectly.
Where DBC Utility helps
J1939 files can be large. A full J1939 DBC can contain many PGNs and thousands of SPNs. In that environment, a text editor is the wrong primary tool.
Use DBC Utility to:
- inspect extended-frame message IDs
- review PGN-style message definitions
- check SPN-like signal scale, offset, unit, and start bit
- compare J1939 DBC revisions
- review mux-heavy or dense payloads
- confirm comments and value tables before release
The important point is discipline: use raw logs to prove what the bus did, then use the DBC to prove how the payload should be interpreted.