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.
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
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | Sync 1 — 0xB5 |
| 1 | 1 | Sync 2 — 0x62 |
| 2 | 1 | Class — the message family |
| 3 | 1 | ID — the message within that family |
| 4 | 2 | Length of the payload, little-endian |
| 6 | len | Payload |
| 6 + len | 2 | Checksum — CK_A then CK_B |
A message is named by its class and ID together. The ones that matter here:
| Class | ID | Message | ||
|---|---|---|---|---|
0x01 | NAV | 0x07 | PVT | position, velocity and time — the one to parse |
0x05 | ACK | 0x01 | ACK | the receiver accepted a configuration message |
0x05 | ACK | 0x00 | NAK | the receiver rejected one |
0x06 | CFG | 0x00 | PRT | port settings — baud rate, protocols in and out |
0x06 | CFG | 0x01 | MSG | turn a message on or off and set its rate |
0x06 | CFG | 0x08 | RATE | the navigation solution rate |
0x06 | CFG | 0x24 | NAV5 | navigation engine settings, including the dynamic model |
Checksum
An 8-bit Fletcher checksum, computed over the class, ID, length and payload — not 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.
NAV-PVT
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:
| Offset | Type | Field | Scale / unit |
|---|---|---|---|
| 0 | uint32 | iTOW | ms, GPS time of week |
| 4 | uint16 | year | UTC |
| 6 | uint8 | month | 1–12 |
| 7 | uint8 | day | 1–31 |
| 8 | uint8 | hour | 0–23 |
| 9 | uint8 | min | 0–59 |
| 10 | uint8 | sec | 0–59 |
| 11 | uint8 | valid | time validity flags |
| 12 | uint32 | tAcc | ns, time accuracy |
| 16 | int32 | nano | ns, fraction of a second |
| 20 | uint8 | fixType | see below |
| 21 | uint8 | flags | fix status flags |
| 22 | uint8 | flags2 | |
| 23 | uint8 | numSV | satellites used in the solution |
| 24 | int32 | lon | 1e-7 deg |
| 28 | int32 | lat | 1e-7 deg |
| 32 | int32 | height | mm above the ellipsoid |
| 36 | int32 | hMSL | mm above mean sea level |
| 40 | uint32 | hAcc | mm, horizontal accuracy |
| 44 | uint32 | vAcc | mm, vertical accuracy |
| 48 | int32 | velN | mm/s north |
| 52 | int32 | velE | mm/s east |
| 56 | int32 | velD | mm/s down |
| 60 | int32 | gSpeed | mm/s, 2-D ground speed |
| 64 | int32 | headMot | 1e-5 deg, heading of motion |
| 68 | uint32 | sAcc | mm/s, speed accuracy |
| 72 | uint32 | headAcc | 1e-5 deg, heading accuracy |
| 76 | uint16 | pDOP | 0.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
| Value | Meaning |
|---|---|
| 0 | no fix |
| 1 | dead reckoning only |
| 2 | 2-D fix |
| 3 | 3-D fix — what you are waiting for |
| 4 | GNSS plus dead reckoning |
| 5 | time only |
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-bitfloatcannot 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;
}
0xB5If 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:
- 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.
- CFG-RATE — the navigation solution rate, given as an interval in milliseconds. 125 ms is 8 Hz.
- CFG-NAV5 — the navigation engine, principally the dynamic model.
- CFG-MSG — turn NAV-PVT on, at one message per solution.
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.
| Model | Suits |
|---|---|
| Portable | the default, general purpose |
| Stationary | a fixed installation, such as an RTK base |
| Pedestrian | walking speed, near the ground |
| Automotive | a road vehicle |
| Sea | a boat |
| Airborne 1 g / 2 g / 4 g | aircraft, 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.
Related guides
- GPS Module User Manual
- MG-F10-C Dual-Band GNSS — User Manual
- u-blox GPS Baud Rate Configuration
- Drone GPS & Compass Guide
- Micolink Protocol
- What Is RTK?
Where to buy
u-blox GPS modules that ship configured for UBX and NAV-PVT, from Canada with free Canada-wide shipping:
- MicoAir M10G-5883 — u-blox M10 with a compass
- MicoAir MG-903 — u-blox M9, four constellations
- MicoAir MG-F10-C — u-blox NEO-F10N, dual-band L1+L5
Written and maintained by the Robofusion engineering team.