Skip to main content

UBX Protocol and NAV-PVT

UBX is u-blox's binary protocol for talking to its GPS receivers, as an alternative to the text NMEA sentences the same modules can produce. You need it for two reasons: it is the only way to configure the receiver, and its NAV-PVT message carries the whole navigation solution in one frame.

This page is for reading a GPS module directly — from a microcontroller, a companion computer, or a test rig. If you only want the module working on a flight controller, you do not need any of it: the firmware speaks UBX for you. See the GPS Module User Manual instead.

What the modules ship configured for

The MicoAir GPS modules leave the factory set to UBX protocol, NAV-PVT message, 115200 baud. So a receiver plugged in fresh is already sending the message this page parses, and the configuration section below is only needed if you want to change the rate, the dynamic model or the port settings.

Frame format

The UBX frame: two sync bytes, class, ID, length, payload and a two-byte Fletcher checksum

OffsetSizeField
01Sync 1 — 0xB5
11Sync 2 — 0x62
21Class — the message family
31ID — the message within that family
42Length of the payload, little-endian
6lenPayload
6 + len2ChecksumCK_A then CK_B

A message is named by its class and ID together. The ones that matter here:

ClassIDMessage
0x01NAV0x07PVTposition, velocity and time — the one to parse
0x05ACK0x01ACKthe receiver accepted a configuration message
0x05ACK0x00NAKthe receiver rejected one
0x06CFG0x00PRTport settings — baud rate, protocols in and out
0x06CFG0x01MSGturn a message on or off and set its rate
0x06CFG0x08RATEthe navigation solution rate
0x06CFG0x24NAV5navigation engine settings, including the dynamic model

Checksum

An 8-bit Fletcher checksum, computed over the class, ID, length and payloadnot the two sync bytes.

Two running bytes. For each input byte: add it to the first, then add the first to the second. Both wrap at 255.

uint8_t ck_a = 0, ck_b = 0;
for (size_t i = 0; i < n; i++) {
ck_a += data[i]; /* data starts at the class byte */
ck_b += ck_a;
}

The second value depending on the running first is what makes this stronger than a plain sum — it catches reordered bytes, which a sum does not.

One message with the entire navigation solution in it. That is the argument for using it: the equivalent in NMEA is spread over several sentences that have to be collected and matched, and NAV-PVT's fields are guaranteed consistent because they come from one solution.

The message is 92 bytes. These are the fields worth having:

OffsetTypeFieldScale / unit
0uint32iTOWms, GPS time of week
4uint16yearUTC
6uint8month1–12
7uint8day1–31
8uint8hour0–23
9uint8min0–59
10uint8sec0–59
11uint8validtime validity flags
12uint32tAccns, time accuracy
16int32nanons, fraction of a second
20uint8fixTypesee below
21uint8flagsfix status flags
22uint8flags2
23uint8numSVsatellites used in the solution
24int32lon1e-7 deg
28int32lat1e-7 deg
32int32heightmm above the ellipsoid
36int32hMSLmm above mean sea level
40uint32hAccmm, horizontal accuracy
44uint32vAccmm, vertical accuracy
48int32velNmm/s north
52int32velEmm/s east
56int32velDmm/s down
60int32gSpeedmm/s, 2-D ground speed
64int32headMot1e-5 deg, heading of motion
68uint32sAccmm/s, speed accuracy
72uint32headAcc1e-5 deg, heading accuracy
76uint16pDOP0.01, position dilution of precision

Fields after offset 77 — further flags, reserved bytes, vehicle heading and magnetic declination — are in u-blox's interface manual for your receiver generation. Almost nothing needs them.

fixType

ValueMeaning
0no fix
1dead reckoning only
22-D fix
33-D fix — what you are waiting for
4GNSS plus dead reckoning
5time only
Everything is a scaled integer

There is no floating point anywhere in NAV-PVT. Latitude arrives as 473977420, meaning 47.3977420°. Ground speed arrives in mm/s.

The consequences are worth stating because they bite:

  • Latitude and longitude need a double. 1e-7 degrees is about 1.1 cm, and a 32-bit float cannot hold nine significant digits — using one throws away roughly a metre of the precision you were given.
  • Millimetres, not centimetres. The unit is easy to assume wrongly, and being out by ten in a velocity is not always obvious in testing.
  • Heading is 1e-5 degrees, so 35.7° arrives as 3570000.

A reference parser in C

Byte at a time, accumulating the checksum as it goes. It captures the class, ID and payload of any UBX message and reports when a complete, valid one has arrived.

#include <stdbool.h>
#include <stdint.h>

#define UBX_SYNC1 0xB5
#define UBX_SYNC2 0x62
#define UBX_MAX_PAYLOAD 256

#define UBX_CLASS_NAV 0x01
#define UBX_NAV_PVT 0x07

typedef struct {
uint8_t msg_class;
uint8_t msg_id;
uint16_t length;
uint8_t payload[UBX_MAX_PAYLOAD];

/* parser state */
uint8_t state;
uint16_t index;
uint8_t ck_a, ck_b;
} ubx_parser_t;

/* Returns true on the byte that completes a valid frame. */
bool ubx_feed(ubx_parser_t *p, uint8_t b)
{
switch (p->state) {
case 0:
if (b == UBX_SYNC1) p->state = 1;
break;

case 1:
/* a second 0xB5 is still a possible start, so stay put */
if (b == UBX_SYNC2) p->state = 2;
else if (b != UBX_SYNC1) p->state = 0;
break;

case 2: /* class — checksum starts here */
p->msg_class = b;
p->ck_a = b;
p->ck_b = b;
p->state = 3;
break;

case 3: /* id */
p->msg_id = b;
p->ck_a += b; p->ck_b += p->ck_a;
p->state = 4;
break;

case 4: /* length, low byte */
p->length = b;
p->ck_a += b; p->ck_b += p->ck_a;
p->state = 5;
break;

case 5: /* length, high byte */
p->length |= (uint16_t)b << 8;
p->ck_a += b; p->ck_b += p->ck_a;
p->index = 0;
if (p->length > UBX_MAX_PAYLOAD) {
p->state = 0; /* too big for us — skip it */
} else {
p->state = (p->length == 0) ? 7 : 6;
}
break;

case 6: /* payload */
p->payload[p->index++] = b;
p->ck_a += b; p->ck_b += p->ck_a;
if (p->index == p->length) p->state = 7;
break;

case 7: /* CK_A */
p->state = (b == p->ck_a) ? 8 : 0;
break;

case 8: /* CK_B */
p->state = 0;
return (b == p->ck_b);

default:
p->state = 0;
break;
}

return false;
}
Why state 1 tolerates a second 0xB5

If the stream contains B5 B5 62 ..., a parser that resets to state 0 on any non-0x62 throws away the 0xB5 that really did start the frame, and loses the message. Staying in state 1 on a repeated sync byte costs one comparison and removes the case entirely.

Decoding the payload, assembling little-endian values by hand so it works on any target:

#include <math.h>

static uint32_t rd_u32(const uint8_t *b) {
return (uint32_t)b[0] | ((uint32_t)b[1] << 8) |
((uint32_t)b[2] << 16) | ((uint32_t)b[3] << 24);
}
static int32_t rd_i32(const uint8_t *b) { return (int32_t)rd_u32(b); }
static uint16_t rd_u16(const uint8_t *b) { return (uint16_t)b[0] | ((uint16_t)b[1] << 8); }

typedef struct {
double latitude; /* deg */
double longitude; /* deg */
float altitude_msl; /* m */
float vel_n, vel_e, vel_d; /* m/s */
float ground_speed; /* m/s */
float heading; /* deg */
float h_acc, v_acc; /* m */
float s_acc; /* m/s */
float pdop;
uint8_t fix_type;
uint8_t num_sv;
uint16_t year;
uint8_t month, day, hour, minute, second;
} gps_fix_t;

bool ubx_read_pvt(const ubx_parser_t *p, gps_fix_t *g)
{
if (p->msg_class != UBX_CLASS_NAV || p->msg_id != UBX_NAV_PVT || p->length < 78) {
return false;
}

const uint8_t *d = p->payload;

g->year = rd_u16(d + 4);
g->month = d[6]; g->day = d[7];
g->hour = d[8]; g->minute = d[9]; g->second = d[10];

g->fix_type = d[20];
g->num_sv = d[23];

g->longitude = rd_i32(d + 24) * 1e-7; /* double, not float */
g->latitude = rd_i32(d + 28) * 1e-7;
g->altitude_msl = rd_i32(d + 36) * 1e-3f; /* mm -> m */
g->h_acc = rd_u32(d + 40) * 1e-3f;
g->v_acc = rd_u32(d + 44) * 1e-3f;
g->vel_n = rd_i32(d + 48) * 1e-3f; /* mm/s -> m/s */
g->vel_e = rd_i32(d + 52) * 1e-3f;
g->vel_d = rd_i32(d + 56) * 1e-3f;
g->ground_speed = rd_i32(d + 60) * 1e-3f;
g->heading = rd_i32(d + 64) * 1e-5f; /* 1e-5 deg -> deg */
g->s_acc = rd_u32(d + 68) * 1e-3f;
g->pdop = rd_u16(d + 76) * 0.01f;

return true;
}

The same thing in Python

import struct
import serial

SYNC = b"\xb5\x62"
NAV, PVT = 0x01, 0x07

def ubx_frames(port, baud=115200):
"""Yield (class, id, payload) for each frame with a valid checksum."""
ser = serial.Serial(port, baud, timeout=1)
buf = bytearray()

while True:
buf.extend(ser.read(256) or b"")

while True:
start = buf.find(SYNC)
if start < 0:
del buf[:-1] # keep one byte: sync may straddle reads
break
del buf[:start]

if len(buf) < 6:
break

length = int.from_bytes(buf[4:6], "little")
total = 6 + length + 2
if len(buf) < total:
break

frame = bytes(buf[:total])
ck_a = ck_b = 0
for byte in frame[2:-2]:
ck_a = (ck_a + byte) & 0xFF
ck_b = (ck_b + ck_a) & 0xFF

if (ck_a, ck_b) == (frame[-2], frame[-1]):
del buf[:total]
yield frame[2], frame[3], frame[6:-2]
else:
del buf[:2] # bad checksum — resync past this candidate

# Offsets 0 to 77 of NAV-PVT. The message is 92 bytes; the rest is not
# needed here, so only the first 78 are unpacked.
PVT_FMT = "<IHBBBBBBIiBBBBiiiiIIiiiiiIIH"
assert struct.calcsize(PVT_FMT) == 78

def read_pvt(payload):
(itow, year, month, day, hour, minute, sec, valid, t_acc, nano,
fix_type, flags, flags2, num_sv, lon, lat, height, h_msl,
h_acc, v_acc, vel_n, vel_e, vel_d, g_speed, head_mot,
s_acc, head_acc, p_dop) = struct.unpack(PVT_FMT, payload[:78])
return dict(
utc=f"{year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{sec:02d}",
fix_type=fix_type, num_sv=num_sv,
lat=lat * 1e-7, lon=lon * 1e-7,
alt_msl=h_msl / 1000.0,
h_acc=h_acc / 1000.0, v_acc=v_acc / 1000.0,
vel_n=vel_n / 1000.0, vel_e=vel_e / 1000.0, vel_d=vel_d / 1000.0,
speed=g_speed / 1000.0, heading=head_mot * 1e-5,
pdop=p_dop * 0.01,
)

for cls, msg_id, payload in ubx_frames("COM7"):
if (cls, msg_id) != (NAV, PVT):
continue
f = read_pvt(payload)
if f["fix_type"] < 3:
print(f"no 3D fix — {f['num_sv']} satellites")
continue
print(f"{f['lat']:.7f}, {f['lon']:.7f} {f['alt_msl']:7.1f} m "
f"{f['speed']:5.2f} m/s {f['num_sv']:2d} sats ±{f['h_acc']:.2f} m")

Configuring the receiver

Only needed if the defaults do not suit you. Every configuration message is a normal UBX frame with class 0x06, and the receiver answers each one with ACK-ACK or ACK-NAK — worth reading, because a NAK is the difference between "ignored" and "not supported".

A sensible order after power-up, leaving a short gap between messages:

  1. CFG-PRT — the port: baud rate, and which protocols are accepted in and produced out. Accepting UBX, NMEA and RTCM in while emitting only UBX is the usual choice; RTCM in is what lets you feed RTK corrections later.
  2. CFG-RATE — the navigation solution rate, given as an interval in milliseconds. 125 ms is 8 Hz.
  3. CFG-NAV5 — the navigation engine, principally the dynamic model.
  4. CFG-MSG — turn NAV-PVT on, at one message per solution.
Give the module time before configuring it

A u-blox receiver needs a moment after power before it will accept configuration. Waiting a few hundred milliseconds after power-up, and a few milliseconds between messages, avoids a class of problem where the first message or two are silently lost.

The dynamic model matters more than it sounds

CFG-NAV5 sets what kind of motion the receiver should expect, and it uses that assumption to filter the solution.

ModelSuits
Portablethe default, general purpose
Stationarya fixed installation, such as an RTK base
Pedestrianwalking speed, near the ground
Automotivea road vehicle
Seaa boat
Airborne 1 g / 2 g / 4 gaircraft, by expected acceleration

A ground-based model assumes the receiver stays near the ground and does not accelerate hard, and it will fight a climbing or manoeuvring aircraft — lagging the true position and smoothing out real motion. Airborne 2 g is the usual choice for a multirotor.

This is one of the more consequential settings on a GPS module, and it is invisible: nothing in the output says which model is in use.

Update rate is a trade

Faster is not automatically better. The receiver has a fixed amount of processing time per solution, and tracking more satellites costs more of it.

On some u-blox generations, pushing the rate to 10 Hz or above makes the receiver limit how many satellites take part, keeping the strongest signals and dropping the rest. Backing the rate off to 8 Hz can let every visible satellite contribute.

For a multirotor that is usually the better trade: more satellites in the solution beats more solutions per second, because the flight controller's estimator is fusing the GPS with an IMU running hundreds of times faster anyway.

Easily confused points

The checksum excludes the sync bytes. It starts at the class byte.

It is Fletcher, not a sum. Two running values, the second fed by the first.

Latitude and longitude need a double. A float throws away about a metre.

Velocities are mm/s, and heights and accuracies are mm.

Heading is 1e-5 degrees.

A message is class and ID. Neither identifies it alone.

Configuration is acknowledged. Read the ACK-ACK or ACK-NAK instead of assuming.

The dynamic model is invisible in the output and changes the solution.

Where to buy

u-blox GPS modules that ship configured for UBX and NAV-PVT, from Canada with free Canada-wide shipping:


Written and maintained by the Robofusion engineering team.