198н. Mini-project: a GPS receiver — NMEA, PPS and precise time
A GPS module gives you two things. Over UART it sends NMEA text: time, coordinates, fix quality, several sentences once a second. On a separate pin it outputs the PPS pulse, whose edge sits on the second boundary far more accurately than any crystal on the board. From these you get a device that knows where it is and what time it is, and along the way measures its own clocks: how many ticks the timer really counts per second and how fast the RTC watch crystal runs.
Here the receiver is built on a NUCLEO-G474RE. The text goes through a DMA ring
and is parsed byte by byte with a checksum check; PPS is caught by a capture on the 32-bit TIM2
and by an RTC timestamp, and from the timestamps the core computes the RTC_CALR calibration and
the RTC_SHIFTR shift from section 12а.
Tested without a board and without a module. The core (gps_core.c) was run in the
sim_gps.c experiment on synthetic data: our own NMEA sentences with corruption, a byte stream at
9600 bit/s through a DMA ring, PPS with jitter, a missing pulse and a glitch, an RTC
clock with a fast crystal. The experiment was built with gcc on a PC and with arm-none-eabi-gcc for
Cortex-M4; in qemu-system-arm -M netduinoplus2 the output matched the PC line for
line. A Python model (gps_host.py) recomputes the checksums independently and
reproduces the PPS and RTC estimates bit for bit. The firmware gps_g474.c was built with
armclang against the G4 pack headers without warnings; it has not been
run on a board.
References: the NMEA 0183 standard is paid and was not checked here — the sentence format is taken from
the commonly known description, and the examples in this section are our own, not taken from module
documents. RM0440 — the DMAMUX input table, TIM2, RTC; DS12288 — pins
and alternate functions, HSI16 accuracy (table 43); the NUCLEO-G474RE BSP
from the pack — the example clock setup and the board pins. UART and the DMA ring — sections 16 and
16б, timer capture — 13.6, RTC, CALR, SHIFTR and the timestamp —
sections 12 and 12а. Check — _verify/gps198n_check.py.
Terms used in the documentation
Term Meaning NMEA sentence, talker ID an NMEA sentence; who is talking: GP — GPS, GN — several systems RMC, GGA, GSA navigation minimum, fix with altitude, satellites used in the fix checksum XOR of the characters between $and*PPS, pulse per second a pulse at the start of every second fix, HDOP whether there is a position solution; horizontal dilution of precision circular DMA, HT, TC, IDLE DMA in a circle; half, end of the buffer; silence on the line input capture the timer counter latched at the moment of an edge smooth calibration, shift fine RTC calibration; a shift by a fraction of a second
198н.1 What the device does and what it is made of
| Function | How |
|---|---|
| NMEA text | USART1 at 9600 bit/s, DMA1 channel 1 in circular mode, 128-byte ring |
| parsing | byte by byte, XOR checksum; from RMC — time, date, coordinates, from GGA — fix, satellites, altitude |
| clock frequency | PPS to a TIM2 capture (32 bits, 170 MHz), averaging over a window |
| clock | RTC timestamp on the PPS edge: crystal rate → CALR, phase → SHIFTR |
| output | a line once a second to LPUART1 — the ST-LINK virtual COM port |
| Signal | Pin | Peripheral |
|---|---|---|
| module TX → receive | PA10 | USART1_RX, AF7 |
| module receive ← transmit | PA9 | USART1_TX, AF7 |
| PPS | PA0 | TIM2_CH1, AF1 |
| PPS | PC13 | RTC_TS — RTC timestamp |
| virtual COM | PA2, PA3 | LPUART1, AF12 |
Pins and functions follow the DS12288 tables, LPUART1 follows the board BSP. PPS goes to two pins at once: the TIM2 capture and the RTC timestamp each take the edge from their own pin. On the NUCLEO-G474RE, according to the BSP, PC13 also carries the user button — more on this below, in the pitfalls.
The clock is as in the board BSP example: HSI16 / 4 × 85 / 2 = 170 MHz. The accuracy of HSI16 per DS12288 (table 43) is 15.88 to 16.08 MHz at 30 °C, that is −0.75 to +0.5 %, and the trimming step is 0.2…0.4 %. For timekeeping you need a crystal; in the experiment the timer clock runs fast by +12.5 ppm, like a crystal, and the PPS pulse filter in the core accepts deviations up to ±1 % — good enough for HSI16 too.
198н.2 An NMEA sentence and its checksum
A sentence starts with $, then fields separated by commas, and after * come two
hexadecimal checksum digits and the end of line \r\n. The checksum is the XOR of all
characters between $ and *. An example from the experiment:
$GPRMC,120005.00,A,5545.1249,N,03737.5678,E,0.12,254.3,280926,,,A*6F
RMC fields in order: time hhmmss.ss, status (A — data valid,
V — not valid), latitude ddmm.mmmm and hemisphere, longitude dddmm.mmmm and
hemisphere, speed in knots, course, date ddmmyy. GGA adds the fix
quality, the number of satellites, HDOP and altitude. The core parses the sentence in
integers, without float: time in hundredths of a second since midnight, coordinates in
degrees × 10⁷. Minutes are converted to fractions of a degree by dividing by 60:
| Field | Sentence | Core |
|---|---|---|
| time | 120005.00 | 4320500 hundredths — 12:00:05.00 |
| latitude | 5545.1249,N | 557520817 — 55.7520817° |
| longitude | 03737.5678,E | 376261300 — 37.6261300° |
| fix, satellites, HDOP | 1, 09, 0.9 | 1, 9, 9 tenths |
| altitude | 156.3,M | 1563 dm |
The Python model computed the same checksums with its own code (0x6F for RMC, 0x35 for a GSA sentence of 51 characters) and the same degrees using floating-point division.
Parsing is a state machine fed byte by byte: wait for $, accumulate the body and the XOR, after * take
two digits, then the end of line. Everything that does not fit is counted and
discarded: a wrong checksum, $ in the middle of a sentence (the previous one was cut off),
a sentence longer than the 96-byte buffer. The sentence length limit in the commonly known
description is 82 characters; the margin is for non-standard modules.
The core does not look at the source (GP, GN, GL…): RMC is RMC.
case 4:
r->state = 0;
if (c != '\r' && c != '\n') {
r->broken++;
return NMEA_NONE;
}
if (r->cs != r->cs_rx) {
r->bad_cs++;
return NMEA_NONE;
}
With status V the core does not take coordinates from RMC — it keeps the previous ones.
198н.3 Reception: the DMA ring
The module sends a burst of sentences once a second and stays silent between bursts. The bytes
are put by DMA1 into a 128-byte ring in circular mode; the USART1_RX request goes through
DMAMUX (input 24 per RM0440). Where DMA is writing right now is told by the
CNDTR counter: the write position is 128 − CNDTR. The core takes everything from its
read position up to the write position (ring_take, circular reception — section
16б).
The question is when to take it. At 9600 bit/s a byte takes 1.04 ms, and per second there are up to 960 bytes. A 128-byte ring fills in 133.3 ms: you must take the data sooner, otherwise DMA silently overwrites what has not been read. The experiment: 200 seconds, three sentences per second (RMC, GGA, GSA — 199 bytes, 207.3 ms on the line), with corruption: every 37th sentence has a wrong checksum, every 101st has no end of line, once every 50 s there is a 120-character sentence. In total 600 sentences, 40282 bytes.
| When to take | RMC | GGA | GSA | Wrong checksum | Cut off | Too long |
|---|---|---|---|---|---|---|
| half, end of the ring, silence on the line | 192 | 193 | 194 | 16 | 5 | 4 |
| polling every 250 ms | 0 | 0 | 190 | 5 | 5 | 0 |
On the DMA events (half and end of the ring — HT, TC) and the USART event (silence on the
line — IDLE) the core caught exactly the injected errors: 16, 5 and 4, and
lost nothing. During a burst the events come every 66.7 ms, and
IDLE hands over the tail of the burst at once, without waiting for half the ring to fill.
Polling every 250 ms means up to 240 bytes between polls with a 128-byte ring. Only the end of each burst survives: all RMC and GGA are lost, 190 GSA sentences remain. The checksum did not help here: the overwritten sentences did not arrive with a wrong checksum — they did not arrive at all. You can notice this only from the sentence counter.
198н.4 PPS on a TIM2 capture
TIM2 on the G474 is 32-bit. It counts 170 MHz without a prescaler (a tick of 5.88
ns) and on the PPS edge latches the counter into CCR1. The difference between neighbouring
captures is how many timer ticks fit into a GPS second. The difference modulo
2³² is correct even across an overflow: the counter wraps around in 25.26 s,
and a second is much shorter.
The core accumulates differences over a window and returns the clock error in ppb. A difference that is further than ±1 % from 170000000 is not a second: a missing pulse gives two, a glitch gives a half. Such a difference resets the window. The experiment: 1300 seconds, the clock runs fast by +12500 ppb, the PPS edge jitters by ±100 ns, at second 700 the pulse is missing, at second 900.5 there is an extra one.
| Window | Windows | Estimate, ppb | Error bound |
|---|---|---|---|
| 1 s | 1296 | 12300…12694 | ±205.9 |
| 16 s | 79 | 12489…12510 | ±12.9 |
| 64 s | 19 | 12498…12502 | ±3.2 |
| 256 s | 3 | 12500 | ±0.8 |
Three differences were rejected: one at the missing pulse and two at the glitch. The bound is the jitter of two edges plus one timer tick, divided by the window: the sum of differences over a window is simply the last capture minus the first, and the jitter of the inner edges cancels out. That is why accuracy grows as 1/window and not as a square root. The Python model reproduced all four rows.
The sum is in 64 bits. Over 64 s, 10880136016 ticks accumulate, while
the difference of 32-bit captures "last minus first" gives 2290201424 —
two full turns of 2³² are lost. So the core adds up the per-second
differences in an int64_t instead of subtracting the ends of the window.
198н.5 The RTC clock from PPS
The clock runs from the LSE crystal, with dividers PREDIV_A = 3, PREDIV_S = 8191:
a second is divided into 8192 steps of 122 µs, and calibration works
fully (12а.2). PPS on the RTC_TS pin latches the calendar seconds and the SS counter
into the timestamp. The clock phase at the edge is seconds ×
8192 + (8191 − SS); comparing two phases 1024 s apart gives the crystal rate with
a step of 1 / (1024 × 8192) — 0.119 ppm.
Then come the formulas of 12а: CALM − 512 · CALP ≈ 2²⁰ × the error, and the shift by a
fraction of a second is SHIFTR. The experiment: the crystal is slow by 37.2 ppm, the clock
started with a phase of 5027/8192 s.
| Step | Result |
|---|---|
| rate over 1024 s | −37193 ppb (actually −37200) |
CALR |
0x81D9: CALP = 1, CALM = 473, effect +37195 ppb |
| full search of 1024 combinations | the same 0x81D9 |
| rate after calibration | −6 ppb; a second 1024 s window measures 0 — less than one step |
| drift per day | −3.214 s before calibration, −518 µs after |
| phase at the edge | SS = 3476 — the clock is 0.5756 s past the second boundary |
SHIFTR |
0x8000126B: ADD1S and SUBFS = 4715 — forward by 1 − 0.5756 s |
| next edge | SS = 8191 — the RTC second boundary is on the PPS edge |
More than half a second — it is better to shift forward with ADD1S; less — shift
back with a single SUBFS. The number of seconds after the shift is checked against the time from
RMC: SHIFTR corrects only the fraction, the calendar is set separately (12а).
198н.6 Which second a sentence belongs to. The firmware
PPS marks the start of a second, and the sentence with the time arrives later — in the experiment the first sentence of a burst starts 50 ms after the edge. Which second it describes — the one that has just begun or the next one — is decided by the module, and there is no commonly known rule for all modules. In the model a sentence describes the second that has just begun; for a real module you have to take this from its description or check it: compare the RMC time with the pulse number over a few minutes.
The firmware gps_g474.c is all on CMSIS registers. The DMA and USART interrupts
have the same priority, so ring_take cannot be re-entered.
The PPS capture reads CCR1, and interrupt latency does not affect the result:
the edge time is already latched. The RTC timestamp is read before TSF is cleared —
clearing it resets the timestamp (12а.6). Build-time checks:
- HSI16 / 4 × 85 / 2 = 170 MHz;
- USART1
BRR= 170 MHz / 9600 = 17708.3 → 17708; - LPUART1
BRR= 256 · f / 115200 within 0x300 to 2²⁰; - 32768 / 4 / 8192 = 1 Hz;
- the ring holds more than 100 ms of reception.
armclang 6.24 built the core and the firmware without warnings: 2010 and 1712 bytes of code, 352 bytes of RAM.
198н.7 Pitfalls
Polling the ring less often than it fills. 250 ms versus 133 ms: all
RMC and GGA are lost, and there is no wrong checksum — the sentences simply vanished.
Take data on HT, TC and IDLE.
A difference in 32 bits. You cannot subtract captures 64 s apart — two turns of 2³² will be lost. Add up the per-second differences in 64 bits.
Every pulse is a second. A missing PPS gives two seconds, a glitch gives a half. Without the ±1 % check a single glitch spoils the window estimate.
Too few seconds in the window. Over 1 s the estimate wanders by ±0.2 ppm, over 256 s by less than a thousandth of that (±0.8 ppb).
Status V. Coordinates in such a sentence are not usable. Check the RMC status
and the GGA fix quality.
A shift without checking the second. SHIFTR corrects the fraction of a second, and the
second number has to be checked against RMC.
The timestamp is cleared before it is read. Clearing TSF resets
TSSSR and TSTR (12а).
The button on PC13. On the NUCLEO-G474RE, according to the BSP, the user button is on the
same pin as RTC_TS: pressing it gives a false timestamp.
Time from HSI16. An error of up to −0.75…+0.5 % — PPS will show it, but the trimming step of 0.2…0.4 % does not let you remove it. For timekeeping you need a crystal.
198н.8 Summary
- NMEA: an XOR checksum between
$and*; RMC and GGA are parsed in integers — time in hundredths of a second, coordinates in degrees × 10⁷. - A 128-byte DMA ring at 9600 bit/s fills in 133.3 ms; on the
HT,TC,IDLEevents all 579 intact sentences out of 600 were received, with polling every 250 ms — not a single RMC or GGA. - PPS on the TIM2 170 MHz counter: the estimate error falls as 1/window — ±205.9 ppb over 1 s, ±0.8 over 256 s; differences are kept in 64 bits, the counter wraps around in 25.26 s.
- RTC from timestamps on PPS: a rate of −37.2 ppm measured over 1024 s with a step of 0.119,
CALR= 0x81D9, drift per day −3.214 s → −518 µs;SHIFTR0x8000126B puts the second boundary on the PPS edge. - The Python model reproduced the PPS and RTC estimates bit for bit.
- armclang: 2010 + 1712 bytes of code, 352 bytes of RAM.
Full sources
gps_core.c — the GPS receiver core.
/* gps_core.c — раздел 198н: ядро приёмника GPS (см. gps_core.h). */
#include "gps_core.h"
_Static_assert(NMEA_MAX >= 82u, "строка NMEA 0183 — до 82 символов, буфер не меньше");
/* ── NMEA: поля ────────────────────────────────────────────────────────── */
static uint32_t field(const char *s, uint32_t n, unsigned idx, const char **p)
{
uint32_t i = 0;
while (idx && i < n) { /* пропустить idx запятых */
if (s[i++] == ',')
idx--;
}
if (idx)
return 0;
*p = s + i;
uint32_t k = i;
while (k < n && s[k] != ',')
k++;
return k - i;
}
/* десятичное число с frac знаками после точки: «4507.1234», frac = 4 → 45071234 */
static int fixed(const char *p, uint32_t len, unsigned frac, int32_t *out)
{
int32_t v = 0, sign = 1;
unsigned got = 0, dot = 0, digits = 0;
if (len && *p == '-') {
sign = -1;
p++;
len--;
}
for (uint32_t i = 0; i < len; i++) {
char c = p[i];
if (c == '.' && !dot) {
dot = 1;
continue;
}
if (c < '0' || c > '9')
return -1;
if (dot && got == frac)
continue; /* лишние знаки отбросить */
v = v * 10 + (c - '0');
digits++;
got += dot;
}
if (!digits)
return -1;
while (got++ < frac)
v *= 10;
*out = sign * v;
return 0;
}
/* ddmm.mmmm или dddmm.mmmm → градусы × 10⁷ */
static int coord(const char *p, uint32_t len, const char *h, uint32_t hl, int32_t *out)
{
int32_t v;
if (fixed(p, len, 5, &v) || v < 0 || hl != 1)
return -1;
int32_t deg = v / 10000000, min_e5 = v % 10000000; /* минуты × 10⁵ */
int32_t e7 = deg * 10000000 + (int32_t)(((int64_t)min_e5 * 100 + 30) / 60);
*out = (*h == 'S' || *h == 'W') ? -e7 : e7;
return 0;
}
static int utc(const char *p, uint32_t len, uint32_t *cs)
{
int32_t v;
if (fixed(p, len, 2, &v) || v < 0)
return -1;
uint32_t hh = (uint32_t)v / 1000000u, mm = (uint32_t)v / 10000u % 100u, ss = (uint32_t)v / 100u % 100u;
if (hh > 23u || mm > 59u || ss > 60u)
return -1;
*cs = ((hh * 60u + mm) * 60u + ss) * 100u + (uint32_t)v % 100u;
return 0;
}
static int parse(const char *s, uint32_t n, nmea_fix *f)
{
const char *p, *h;
uint32_t l, hl;
int32_t v;
if (n < 6u || s[5] != ',')
return NMEA_OTHER;
int rmc = s[2] == 'R' && s[3] == 'M' && s[4] == 'C'; /* GP, GN, GL… — любой источник */
int gga = s[2] == 'G' && s[3] == 'G' && s[4] == 'A';
if (!rmc && !gga)
return NMEA_OTHER;
nmea_fix t = *f;
l = field(s, n, 1, &p);
if (utc(p, l, &t.time_cs))
return NMEA_OTHER;
if (rmc) {
l = field(s, n, 2, &p);
t.status = l == 1u ? *p : 'V';
l = field(s, n, 3, &p);
hl = field(s, n, 4, &h);
if (t.status == 'A' && coord(p, l, h, hl, &t.lat_e7))
return NMEA_OTHER;
l = field(s, n, 5, &p);
hl = field(s, n, 6, &h);
if (t.status == 'A' && coord(p, l, h, hl, &t.lon_e7))
return NMEA_OTHER;
l = field(s, n, 9, &p);
if (fixed(p, l, 0, &v) == 0)
t.date = (uint32_t)v;
} else {
l = field(s, n, 6, &p);
t.fix = (uint8_t)(fixed(p, l, 0, &v) ? 0 : v);
l = field(s, n, 7, &p);
t.sats = (uint8_t)(fixed(p, l, 0, &v) ? 0 : v);
l = field(s, n, 8, &p);
t.hdop_x10 = (uint16_t)(fixed(p, l, 1, &v) ? 0 : v);
if (t.fix) {
l = field(s, n, 2, &p);
hl = field(s, n, 3, &h);
if (coord(p, l, h, hl, &t.lat_e7))
return NMEA_OTHER;
l = field(s, n, 4, &p);
hl = field(s, n, 5, &h);
if (coord(p, l, h, hl, &t.lon_e7))
return NMEA_OTHER;
l = field(s, n, 9, &p);
if (fixed(p, l, 1, &t.alt_dm))
return NMEA_OTHER;
}
}
*f = t;
return rmc ? NMEA_RMC : NMEA_GGA;
}
uint8_t nmea_checksum(const char *body, uint32_t n)
{
uint8_t cs = 0;
while (n--)
cs ^= (uint8_t)*body++;
return cs;
}
static int hex(char c)
{
return c >= '0' && c <= '9' ? c - '0' : c >= 'A' && c <= 'F' ? c - 'A' + 10 : c >= 'a' && c <= 'f' ? c - 'a' + 10 : -1;
}
int nmea_feed(nmea_rx *r, char c, nmea_fix *f)
{
if (c == '$') {
if (r->state)
r->broken++; /* начало новой строки посреди старой */
r->state = 1;
r->n = 0;
r->cs = 0;
return NMEA_NONE;
}
switch (r->state) {
case 1:
if (c == '*') {
r->state = 2;
} else if (c == '\r' || c == '\n') {
r->broken++;
r->state = 0;
} else if (r->n >= NMEA_MAX) {
r->overflow++;
r->state = 0;
} else {
r->buf[r->n++] = c;
r->cs ^= (uint8_t)c;
}
return NMEA_NONE;
case 2:
case 3: {
int h = hex(c);
if (h < 0) {
r->broken++;
r->state = 0;
return NMEA_NONE;
}
r->cs_rx = (uint8_t)(r->state == 2 ? h << 4 : r->cs_rx | h);
r->state++;
return NMEA_NONE;
}
case 4:
r->state = 0;
if (c != '\r' && c != '\n') {
r->broken++;
return NMEA_NONE;
}
if (r->cs != r->cs_rx) {
r->bad_cs++;
return NMEA_NONE;
}
r->buf[r->n] = 0;
{
int t = parse(r->buf, r->n, f);
r->ok += t != NMEA_OTHER;
r->other += t == NMEA_OTHER;
return t;
}
default:
return NMEA_NONE; /* мусор между строками */
}
}
/* ── кольцо DMA ────────────────────────────────────────────────────────── */
uint32_t ring_take(const uint8_t *ring, uint32_t size, uint32_t *rd, uint32_t wr, byte_sink sink, void *ctx)
{
uint32_t n = 0;
while (*rd != wr) {
sink(ring[*rd], ctx);
*rd = *rd + 1u == size ? 0u : *rd + 1u;
n++;
}
return n;
}
/* ── PPS ───────────────────────────────────────────────────────────────── */
void pps_init(pps_est *p, uint32_t nominal, uint32_t window)
{
p->nominal = nominal;
p->window = window;
p->last = p->n = p->rejected = 0;
p->have = 0;
p->sum = 0;
}
int pps_add(pps_est *p, uint32_t capture, int32_t *ppb)
{
if (!p->have) {
p->have = 1;
p->last = capture;
return 0;
}
uint32_t d = capture - p->last; /* по модулю 2³²: переполнение счётчика не мешает */
uint32_t tol = p->nominal / 100u; /* ±1 %: пропуск импульса даёт 2×, помеха — доли */
p->last = capture;
if (d < p->nominal - tol || d > p->nominal + tol) {
p->rejected++;
p->n = 0;
p->sum = 0;
return 0;
}
p->sum += d;
if (++p->n < p->window)
return 0;
int64_t exp = (int64_t)p->nominal * p->n, num = (p->sum - exp) * 1000000000LL;
*ppb = (int32_t)(num >= 0 ? (num + exp / 2) / exp : -((-num + exp / 2) / exp));
p->n = 0;
p->sum = 0;
return 1;
}
/* ── RTC ───────────────────────────────────────────────────────────────── */
int64_t rtc_phase(uint32_t sec, uint32_t ss, uint32_t prediv_s)
{
return (int64_t)sec * (prediv_s + 1u) + (int64_t)prediv_s - ss; /* SS считает вниз */
}
int32_t rtc_drift_ppb(int64_t ph0, int64_t ph1, uint32_t seconds, uint32_t prediv_s)
{
int64_t den = (int64_t)seconds * (prediv_s + 1u);
int64_t num = (ph1 - ph0 - den) * 1000000000LL;
return (int32_t)(num >= 0 ? (num + den / 2) / den : -((-num + den / 2) / den));
}
uint32_t rtc_calr_for(int32_t ppb)
{
int64_t x = (int64_t)ppb * 1048576; /* CALM − 512·CALP ≈ 2²⁰ × ошибку */
int32_t v = (int32_t)(x >= 0 ? (x + 500000000) / 1000000000 : -((-x + 500000000) / 1000000000));
if (v > 511)
v = 511;
if (v < -511)
v = -511;
return v >= 0 ? (uint32_t)v : 0x8000u | (uint32_t)(512 + v);
}
int32_t rtc_calr_ppb(uint32_t calr)
{
int64_t p = calr >> 15 & 1u, m = calr & 0x1FFu;
int64_t num = (512 * p - m) * 1000000000LL, den = 1048576 + m - 512 * p;
return (int32_t)(num >= 0 ? (num + den / 2) / den : -((-num + den / 2) / den));
}
uint32_t rtc_shift_for(uint32_t ss, uint32_t prediv_s)
{
uint32_t frac = prediv_s - ss; /* тактов ck_apre после границы секунды */
uint32_t add1s = 2u * frac >= prediv_s + 1u; /* больше полсекунды — проще вперёд */
return (add1s ? 0x80000000u : 0u) | frac; /* назад на frac или вперёд на 1 − frac */
}
Next: section 198о "Mini-project: an IR remote — receiving and transmitting the NEC protocol".